mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
Consolidate the CLI diagnostic plan, implementation, and test hardening into one reviewable change. The CLI now reports phase and target context for broad failure paths while preserving existing fail-fast behavior for real setup failures and warning-only behavior for optional best-effort work. The workflow unit tests also avoid discovering real local agent CLIs, so developer machines with tools such as gemini installed do not hang pytest during metadata-only assertions. Constraint: CLI setup failures must remain fail-fast, while optional preset and cleanup paths should continue with clear warnings. Rejected: Replace broad handlers across the whole codebase in one pass | too broad for a targeted CLI diagnostic fix Rejected: Add runtime timeouts to workflow agent dispatch | dispatch may legitimately be long-running and the observed hang was test isolation Confidence: high Scope-risk: moderate Directive: Keep future best-effort CLI warnings tied to the failed phase and target so users can diagnose setup state. Tested: uvx ruff check src/; uv run pytest tests/integrations/test_cli.py -v; uv run pytest tests/test_workflows.py::TestCommandStep::test_step_override_integration tests/test_workflows.py::TestPromptStep::test_execute_with_step_integration tests/test_workflows.py::TestPromptStep::test_execute_with_model -vv; uv run pytest Not-tested: Real Nacos/PG/Redis-style external service failure injection; real interactive workflow dispatch against installed gemini CLI
5341 lines
224 KiB
Python
5341 lines
224 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = [
|
|
# "typer",
|
|
# "rich",
|
|
# "platformdirs",
|
|
# "readchar",
|
|
# "json5",
|
|
# "pyyaml",
|
|
# "packaging",
|
|
# ]
|
|
# ///
|
|
"""
|
|
Specify CLI - Setup tool for Specify projects
|
|
|
|
Usage:
|
|
uvx specify-cli.py init <project-name>
|
|
uvx specify-cli.py init .
|
|
uvx specify-cli.py init --here
|
|
|
|
Or install globally:
|
|
uv tool install --from specify-cli.py specify-cli
|
|
specify init <project-name>
|
|
specify init .
|
|
specify init --here
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import zipfile
|
|
import shutil
|
|
import json
|
|
import shlex
|
|
import yaml
|
|
from pathlib import Path
|
|
|
|
from typing import Any, Optional
|
|
|
|
import typer
|
|
from rich.panel import Panel
|
|
from rich.live import Live
|
|
from rich.align import Align
|
|
from rich.table import Table
|
|
from .integration_runtime import (
|
|
invoke_separator_for_integration as _invoke_separator_for_integration,
|
|
resolve_integration_options as _resolve_integration_options_impl,
|
|
with_integration_setting as _with_integration_setting,
|
|
)
|
|
from .integration_state import (
|
|
INTEGRATION_JSON,
|
|
INTEGRATION_STATE_SCHEMA,
|
|
dedupe_integration_keys as _dedupe_integration_keys,
|
|
default_integration_key as _default_integration_key,
|
|
installed_integration_keys as _installed_integration_keys,
|
|
integration_setting as _integration_setting,
|
|
integration_settings as _integration_settings,
|
|
try_read_integration_json as _try_read_integration_json,
|
|
write_integration_json as _write_integration_json_file,
|
|
)
|
|
from .shared_infra import (
|
|
install_shared_infra as _install_shared_infra_impl,
|
|
refresh_shared_templates as _refresh_shared_templates_impl,
|
|
)
|
|
|
|
from ._console import (
|
|
BANNER as BANNER,
|
|
TAGLINE as TAGLINE,
|
|
BannerGroup,
|
|
StepTracker,
|
|
console,
|
|
get_key as get_key,
|
|
select_with_arrows,
|
|
show_banner,
|
|
)
|
|
from ._assets import (
|
|
_locate_bundled_extension,
|
|
_locate_bundled_preset,
|
|
_locate_bundled_workflow,
|
|
_locate_core_pack,
|
|
_repo_root,
|
|
get_speckit_version as get_speckit_version,
|
|
)
|
|
from ._utils import (
|
|
CLAUDE_LOCAL_PATH as CLAUDE_LOCAL_PATH,
|
|
CLAUDE_NPM_LOCAL_PATH as CLAUDE_NPM_LOCAL_PATH,
|
|
_display_project_path,
|
|
check_tool as check_tool,
|
|
handle_vscode_settings as handle_vscode_settings,
|
|
init_git_repo as init_git_repo,
|
|
is_git_repo as is_git_repo,
|
|
merge_json_files as merge_json_files,
|
|
run_command as run_command,
|
|
)
|
|
from ._version import (
|
|
GITHUB_API_LATEST as GITHUB_API_LATEST,
|
|
self_app as _self_app,
|
|
self_check as self_check,
|
|
self_upgrade as self_upgrade,
|
|
)
|
|
|
|
def _build_agent_config() -> dict[str, dict[str, Any]]:
|
|
"""Derive AGENT_CONFIG from INTEGRATION_REGISTRY."""
|
|
from .integrations import INTEGRATION_REGISTRY
|
|
config: dict[str, dict[str, Any]] = {}
|
|
for key, integration in INTEGRATION_REGISTRY.items():
|
|
if integration.config:
|
|
config[key] = dict(integration.config)
|
|
return config
|
|
|
|
AGENT_CONFIG = _build_agent_config()
|
|
DEFAULT_INIT_INTEGRATION = "copilot"
|
|
|
|
AI_ASSISTANT_ALIASES = {
|
|
"kiro": "kiro-cli",
|
|
}
|
|
|
|
# Agents that use TOML command format (others use Markdown)
|
|
_TOML_AGENTS = frozenset({"gemini", "tabnine"})
|
|
|
|
def _build_ai_assistant_help() -> str:
|
|
"""Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config."""
|
|
|
|
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 = _build_ai_assistant_help()
|
|
|
|
|
|
def _build_integration_equivalent(
|
|
integration_key: str,
|
|
ai_commands_dir: str | None = None,
|
|
) -> str:
|
|
"""Build the modern --integration equivalent for legacy --ai usage."""
|
|
|
|
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:
|
|
"""Build the legacy --ai deprecation warning message."""
|
|
|
|
replacement = _build_integration_equivalent(
|
|
integration_key,
|
|
ai_commands_dir=ai_commands_dir,
|
|
)
|
|
return (
|
|
"[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n"
|
|
f"Use [bold]{replacement}[/bold] instead."
|
|
)
|
|
|
|
def _stdin_is_interactive() -> bool:
|
|
return sys.stdin.isatty()
|
|
|
|
SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
|
|
|
app = typer.Typer(
|
|
name="specify",
|
|
help="Setup tool for Specify spec-driven development projects",
|
|
add_completion=False,
|
|
invoke_without_command=True,
|
|
cls=BannerGroup,
|
|
)
|
|
|
|
def _version_callback(value: bool):
|
|
if value:
|
|
console.print(f"specify {get_speckit_version()}")
|
|
raise typer.Exit()
|
|
|
|
@app.callback()
|
|
def callback(
|
|
ctx: typer.Context,
|
|
version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."),
|
|
):
|
|
"""Show banner when no subcommand is provided."""
|
|
if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv:
|
|
show_banner()
|
|
console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]"))
|
|
console.print()
|
|
|
|
def _refresh_shared_templates(
|
|
project_path: Path,
|
|
*,
|
|
invoke_separator: str,
|
|
force: bool = False,
|
|
) -> None:
|
|
"""Refresh default-sensitive shared templates without touching scripts."""
|
|
_refresh_shared_templates_impl(
|
|
project_path,
|
|
version=get_speckit_version(),
|
|
core_pack=_locate_core_pack(),
|
|
repo_root=_repo_root(),
|
|
console=console,
|
|
invoke_separator=invoke_separator,
|
|
force=force,
|
|
)
|
|
|
|
|
|
def _install_shared_infra(
|
|
project_path: Path,
|
|
script_type: str,
|
|
tracker: StepTracker | None = None,
|
|
force: bool = False,
|
|
invoke_separator: str = ".",
|
|
refresh_managed: bool = False,
|
|
refresh_hint: str | None = None,
|
|
) -> bool:
|
|
"""Install shared infrastructure files into *project_path*.
|
|
|
|
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
|
|
the bundled core_pack or source checkout, where ``<variant>`` is
|
|
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
|
|
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
|
|
|
|
Page templates are processed to resolve ``__SPECKIT_COMMAND_<NAME>__``
|
|
placeholders using *invoke_separator* (``"."`` for markdown agents,
|
|
``"-"`` for skills agents).
|
|
|
|
Overwrite policy:
|
|
|
|
* ``force=True`` — overwrite every existing file (still skips symlinks
|
|
to avoid following links outside the project root).
|
|
* ``refresh_managed=True`` — overwrite only files whose on-disk hash
|
|
still matches the previously recorded manifest hash (i.e. unmodified
|
|
files installed by spec-kit). Files with diverging hashes are
|
|
treated as user customizations and preserved with a warning.
|
|
* Default — only add missing files; existing ones are skipped.
|
|
|
|
*refresh_hint* — caller-supplied rich-text fragment shown after the
|
|
"Preserved customized files" warning to tell the user which flag/command
|
|
they should re-run with to overwrite their customizations. Each caller
|
|
passes the flag that's actually valid in its CLI surface (e.g.
|
|
``--refresh-shared-infra`` for ``integration switch``,
|
|
``--force`` for ``init``/``integration upgrade``). When ``None``, no
|
|
remediation hint is printed for customizations.
|
|
|
|
Returns ``True`` on success.
|
|
"""
|
|
return _install_shared_infra_impl(
|
|
project_path,
|
|
script_type,
|
|
version=get_speckit_version(),
|
|
core_pack=_locate_core_pack(),
|
|
repo_root=_repo_root(),
|
|
console=console,
|
|
force=force,
|
|
invoke_separator=invoke_separator,
|
|
refresh_managed=refresh_managed,
|
|
refresh_hint=refresh_hint,
|
|
)
|
|
|
|
|
|
def _install_shared_infra_or_exit(
|
|
project_path: Path,
|
|
script_type: str,
|
|
tracker: StepTracker | None = None,
|
|
force: bool = False,
|
|
invoke_separator: str = ".",
|
|
refresh_managed: bool = False,
|
|
refresh_hint: str | None = None,
|
|
) -> bool:
|
|
try:
|
|
return _install_shared_infra(
|
|
project_path,
|
|
script_type,
|
|
tracker=tracker,
|
|
force=force,
|
|
invoke_separator=invoke_separator,
|
|
refresh_managed=refresh_managed,
|
|
refresh_hint=refresh_hint,
|
|
)
|
|
except (ValueError, OSError) as exc:
|
|
console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
|
|
"""Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows)."""
|
|
if os.name == "nt":
|
|
return # Windows: skip silently
|
|
scan_roots = [
|
|
project_path / ".specify" / "scripts",
|
|
project_path / ".specify" / "extensions",
|
|
]
|
|
failures: list[str] = []
|
|
updated = 0
|
|
for scripts_root in scan_roots:
|
|
if not scripts_root.is_dir():
|
|
continue
|
|
for script in scripts_root.rglob("*.sh"):
|
|
try:
|
|
if script.is_symlink() or not script.is_file():
|
|
continue
|
|
try:
|
|
with script.open("rb") as f:
|
|
if f.read(2) != b"#!":
|
|
continue
|
|
except Exception:
|
|
continue
|
|
st = script.stat()
|
|
mode = st.st_mode
|
|
if mode & 0o111:
|
|
continue
|
|
new_mode = mode
|
|
if mode & 0o400:
|
|
new_mode |= 0o100
|
|
if mode & 0o040:
|
|
new_mode |= 0o010
|
|
if mode & 0o004:
|
|
new_mode |= 0o001
|
|
if not (new_mode & 0o100):
|
|
new_mode |= 0o100
|
|
os.chmod(script, new_mode)
|
|
updated += 1
|
|
except Exception as e:
|
|
failures.append(f"{_display_project_path(project_path, script)}: {e}")
|
|
if tracker:
|
|
detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "")
|
|
tracker.add("chmod", "Set script permissions recursively")
|
|
(tracker.error if failures else tracker.complete)("chmod", detail)
|
|
else:
|
|
if updated:
|
|
console.print(f"[cyan]Updated execute permissions on {updated} script(s) recursively[/cyan]")
|
|
if failures:
|
|
console.print("[yellow]Some scripts could not be updated:[/yellow]")
|
|
for f in failures:
|
|
console.print(f" - {f}")
|
|
|
|
def ensure_constitution_from_template(project_path: Path, tracker: StepTracker | None = None) -> None:
|
|
"""Copy constitution template to memory if it doesn't exist (preserves existing constitution on reinitialization)."""
|
|
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
|
|
template_constitution = project_path / ".specify" / "templates" / "constitution-template.md"
|
|
|
|
# If constitution already exists in memory, preserve it
|
|
if memory_constitution.exists():
|
|
if tracker:
|
|
tracker.add("constitution", "Constitution setup")
|
|
tracker.skip("constitution", "existing file preserved")
|
|
return
|
|
|
|
# If template doesn't exist, something went wrong with extraction
|
|
if not template_constitution.exists():
|
|
if tracker:
|
|
tracker.add("constitution", "Constitution setup")
|
|
tracker.error("constitution", "template not found")
|
|
return
|
|
|
|
# Copy template to memory directory
|
|
try:
|
|
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(template_constitution, memory_constitution)
|
|
if tracker:
|
|
tracker.add("constitution", "Constitution setup")
|
|
tracker.complete("constitution", "copied from template")
|
|
else:
|
|
console.print("[cyan]Initialized constitution from template[/cyan]")
|
|
except Exception as e:
|
|
if tracker:
|
|
tracker.add("constitution", "Constitution setup")
|
|
tracker.error("constitution", str(e))
|
|
else:
|
|
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
|
|
|
|
|
|
INIT_OPTIONS_FILE = ".specify/init-options.json"
|
|
|
|
|
|
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
|
|
"""Persist the CLI options used during ``specify init``.
|
|
|
|
Writes a small JSON file to ``.specify/init-options.json`` so that
|
|
later operations (e.g. preset install) can adapt their behaviour
|
|
without scanning the filesystem.
|
|
"""
|
|
dest = project_path / INIT_OPTIONS_FILE
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_text(json.dumps(options, indent=2, sort_keys=True))
|
|
|
|
|
|
def load_init_options(project_path: Path) -> dict[str, Any]:
|
|
"""Load the init options previously saved by ``specify init``.
|
|
|
|
Returns an empty dict if the file does not exist or cannot be parsed.
|
|
"""
|
|
path = project_path / INIT_OPTIONS_FILE
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
return json.loads(path.read_text())
|
|
except (json.JSONDecodeError, OSError):
|
|
return {}
|
|
|
|
|
|
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
|
"""Resolve the agent-specific skills directory.
|
|
|
|
Returns ``project_path / <agent_folder> / "skills"``, falling back
|
|
to ``project_path / ".agents/skills"`` for unknown agents.
|
|
"""
|
|
agent_config = AGENT_CONFIG.get(selected_ai, {})
|
|
agent_folder = agent_config.get("folder", "")
|
|
if agent_folder:
|
|
return project_path / agent_folder.rstrip("/") / "skills"
|
|
return project_path / ".agents" / "skills"
|
|
|
|
|
|
def _cli_error_detail(exc: BaseException) -> str:
|
|
"""Return a compact one-line exception detail for CLI output."""
|
|
detail = str(exc).replace("\n", " ").strip()
|
|
return detail or exc.__class__.__name__
|
|
|
|
|
|
def _cli_phase_label(phase: str, target_kind: str, target: str | None = None) -> str:
|
|
"""Format a stable operation label for user-visible diagnostics."""
|
|
label = f"{phase} {target_kind}".strip()
|
|
if target:
|
|
label = f"{label} '{target}'"
|
|
return label
|
|
|
|
|
|
def _print_cli_warning(
|
|
phase: str,
|
|
target_kind: str,
|
|
target: str | None,
|
|
exc: BaseException,
|
|
*,
|
|
continuing: str | None = None,
|
|
) -> None:
|
|
"""Print a warning that names the failed CLI phase and target."""
|
|
label = _cli_phase_label(phase, target_kind, target)
|
|
console.print(f"[yellow]Warning:[/yellow] Failed to {label}: {_cli_error_detail(exc)}")
|
|
if continuing:
|
|
console.print(f"[dim]{continuing}[/dim]")
|
|
|
|
|
|
# Constants kept for backward compatibility with presets and extensions.
|
|
DEFAULT_SKILLS_DIR = ".agents/skills"
|
|
SKILL_DESCRIPTIONS = {
|
|
"specify": "Create or update feature specifications from natural language descriptions.",
|
|
"plan": "Generate technical implementation plans from feature specifications.",
|
|
"tasks": "Break down implementation plans into actionable task lists.",
|
|
"implement": "Execute all tasks from the task breakdown to build the feature.",
|
|
"analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md.",
|
|
"clarify": "Structured clarification workflow for underspecified requirements.",
|
|
"constitution": "Create or update project governing principles and development guidelines.",
|
|
"checklist": "Generate custom quality checklists for validating requirements completeness and clarity.",
|
|
"taskstoissues": "Convert tasks from tasks.md into GitHub issues.",
|
|
}
|
|
|
|
|
|
@app.command()
|
|
def init(
|
|
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
|
ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
|
|
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
|
|
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
|
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
|
|
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
|
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
|
|
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
|
|
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 (no-op). Previously: show verbose diagnostic output.", hidden=True),
|
|
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
|
|
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
|
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
|
|
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
|
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
|
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
|
|
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
|
):
|
|
"""
|
|
Initialize a new Specify project.
|
|
|
|
By default, project files are downloaded from the latest GitHub release.
|
|
Use --offline to scaffold from assets bundled inside the specify-cli
|
|
package instead (no internet access required, ideal for air-gapped or
|
|
enterprise environments).
|
|
|
|
NOTE: Starting with v0.6.0, bundled assets will be used by default and
|
|
the --offline flag will be removed. The GitHub download path will be
|
|
retired because bundled assets eliminate the need for network access,
|
|
avoid proxy/firewall issues, and guarantee that templates always match
|
|
the installed CLI version.
|
|
|
|
This command will:
|
|
1. Check that required tools are installed (git is optional)
|
|
2. Let you choose your coding agent integration, or default to Copilot
|
|
in non-interactive sessions
|
|
3. Download template from GitHub (or use bundled assets with --offline)
|
|
4. Initialize a fresh git repository (if not --no-git and no existing repo)
|
|
5. Optionally set up coding agent integration commands
|
|
|
|
Examples:
|
|
specify init my-project
|
|
specify init my-project --integration claude
|
|
specify init my-project --integration copilot --no-git
|
|
specify init --ignore-agent-tools my-project
|
|
specify init . --integration claude # Initialize in current directory
|
|
specify init . # Initialize in current directory (interactive integration selection)
|
|
specify init --here --integration claude # Alternative syntax for current directory
|
|
specify init --here --integration codex --integration-options="--skills"
|
|
specify init --here --integration codebuddy
|
|
specify init --here --integration vibe # Initialize with Mistral Vibe support
|
|
specify init --here
|
|
specify init --here --force # Skip confirmation when current directory not empty
|
|
specify init my-project --integration claude # Claude installs skills by default
|
|
specify init --here --integration gemini
|
|
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
|
|
specify init my-project --integration claude --preset healthcare-compliance # With preset
|
|
"""
|
|
|
|
show_banner()
|
|
ai_deprecation_warning: str | None = None
|
|
|
|
# Detect when option values are likely misinterpreted flags (parameter ordering issue)
|
|
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)
|
|
|
|
# --integration and --ai are mutually exclusive
|
|
if integration and ai_assistant:
|
|
console.print("[red]Error:[/red] --integration and --ai are mutually exclusive")
|
|
raise typer.Exit(1)
|
|
|
|
# Resolve the integration — either from --integration or --ai
|
|
from .integrations import INTEGRATION_REGISTRY, get_integration
|
|
if integration:
|
|
resolved_integration = get_integration(integration)
|
|
if not resolved_integration:
|
|
console.print(f"[red]Error:[/red] Unknown integration: '{integration}'")
|
|
available = ", ".join(sorted(INTEGRATION_REGISTRY))
|
|
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
|
raise typer.Exit(1)
|
|
ai_assistant = integration
|
|
elif ai_assistant:
|
|
resolved_integration = get_integration(ai_assistant)
|
|
if not resolved_integration:
|
|
console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}")
|
|
raise typer.Exit(1)
|
|
ai_deprecation_warning = _build_ai_deprecation_warning(
|
|
resolved_integration.key,
|
|
ai_commands_dir=ai_commands_dir,
|
|
)
|
|
|
|
# Deprecation warnings for --ai-skills and --ai-commands-dir (only when
|
|
# an integration has been resolved from --ai or --integration)
|
|
if ai_assistant or integration:
|
|
if ai_skills:
|
|
from .integrations.base import SkillsIntegration as _SkillsCheck
|
|
if isinstance(resolved_integration, _SkillsCheck):
|
|
console.print(
|
|
"[dim]Note: --ai-skills is not needed; "
|
|
"skills are the default for this integration.[/dim]"
|
|
)
|
|
else:
|
|
console.print(
|
|
"[dim]Note: --ai-skills has no effect with "
|
|
f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]"
|
|
)
|
|
if ai_commands_dir and resolved_integration.key != "generic":
|
|
console.print(
|
|
"[dim]Note: --ai-commands-dir is deprecated; "
|
|
'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
|
|
)
|
|
|
|
if no_git:
|
|
console.print(
|
|
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
|
|
"[yellow]The git extension will no longer be enabled by default "
|
|
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
|
|
)
|
|
|
|
if project_name == ".":
|
|
here = True
|
|
project_name = None # Clear project_name to use existing validation logic
|
|
|
|
if here and project_name:
|
|
console.print("[red]Error:[/red] Cannot specify both project name and --here flag")
|
|
raise typer.Exit(1)
|
|
|
|
if not here and not project_name:
|
|
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
|
raise typer.Exit(1)
|
|
|
|
if ai_skills and not ai_assistant:
|
|
console.print("[red]Error:[/red] --ai-skills requires --ai to be specified")
|
|
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
|
|
raise typer.Exit(1)
|
|
|
|
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
|
|
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
|
|
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
|
|
raise typer.Exit(1)
|
|
|
|
dir_existed_before = False
|
|
if here:
|
|
project_name = Path.cwd().name
|
|
project_path = Path.cwd()
|
|
dir_existed_before = True
|
|
|
|
existing_items = list(project_path.iterdir())
|
|
if existing_items:
|
|
console.print(f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)")
|
|
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
|
|
if force:
|
|
console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]")
|
|
else:
|
|
response = typer.confirm("Do you want to continue?")
|
|
if not response:
|
|
console.print("[yellow]Operation cancelled[/yellow]")
|
|
raise typer.Exit(0)
|
|
else:
|
|
project_path = Path(project_name).resolve()
|
|
dir_existed_before = project_path.exists()
|
|
if project_path.exists():
|
|
if not project_path.is_dir():
|
|
console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.")
|
|
raise typer.Exit(1)
|
|
existing_items = list(project_path.iterdir())
|
|
if force:
|
|
if existing_items:
|
|
console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)")
|
|
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
|
|
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
|
|
else:
|
|
error_panel = Panel(
|
|
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
|
|
"Please choose a different project name or remove the existing directory.\n"
|
|
"Use [bold]--force[/bold] to merge into the existing directory.",
|
|
title="[red]Directory Conflict[/red]",
|
|
border_style="red",
|
|
padding=(1, 2)
|
|
)
|
|
console.print()
|
|
console.print(error_panel)
|
|
raise typer.Exit(1)
|
|
|
|
if ai_assistant:
|
|
if ai_assistant not in AGENT_CONFIG:
|
|
console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
|
raise typer.Exit(1)
|
|
selected_ai = ai_assistant
|
|
elif not _stdin_is_interactive():
|
|
console.print(
|
|
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
|
|
"Use --integration to choose a different agent.[/dim]"
|
|
)
|
|
selected_ai = DEFAULT_INIT_INTEGRATION
|
|
else:
|
|
# Create options dict for selection (agent_key: display_name)
|
|
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
|
|
selected_ai = select_with_arrows(
|
|
ai_choices,
|
|
"Choose your coding agent integration:",
|
|
DEFAULT_INIT_INTEGRATION,
|
|
)
|
|
|
|
# Auto-promote interactively selected agents to the integration path
|
|
if not ai_assistant:
|
|
resolved_integration = get_integration(selected_ai)
|
|
if not resolved_integration:
|
|
console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'")
|
|
raise typer.Exit(1)
|
|
|
|
# Validate --ai-commands-dir usage.
|
|
# Skip validation when --integration-options is provided — the integration
|
|
# will validate its own options in setup().
|
|
if selected_ai == "generic" and not integration_options:
|
|
if not ai_commands_dir:
|
|
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic")
|
|
console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]')
|
|
raise typer.Exit(1)
|
|
|
|
current_dir = Path.cwd()
|
|
|
|
setup_lines = [
|
|
"[cyan]Specify Project Setup[/cyan]",
|
|
"",
|
|
f"{'Project':<15} [green]{project_path.name}[/green]",
|
|
f"{'Working Path':<15} [dim]{current_dir}[/dim]",
|
|
]
|
|
|
|
if not here:
|
|
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
|
|
|
|
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
|
|
|
|
should_init_git = False
|
|
if not no_git:
|
|
should_init_git = check_tool("git")
|
|
if not should_init_git:
|
|
console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
|
|
|
|
if not ignore_agent_tools:
|
|
agent_config = AGENT_CONFIG.get(selected_ai)
|
|
if agent_config and agent_config["requires_cli"]:
|
|
install_url = agent_config["install_url"]
|
|
if not check_tool(selected_ai):
|
|
error_panel = Panel(
|
|
f"[cyan]{selected_ai}[/cyan] not found\n"
|
|
f"Install from: [cyan]{install_url}[/cyan]\n"
|
|
f"{agent_config['name']} is required to continue with this project type.\n\n"
|
|
"Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check",
|
|
title="[red]Agent Detection Error[/red]",
|
|
border_style="red",
|
|
padding=(1, 2)
|
|
)
|
|
console.print()
|
|
console.print(error_panel)
|
|
raise typer.Exit(1)
|
|
|
|
if script_type:
|
|
if script_type not in SCRIPT_TYPE_CHOICES:
|
|
console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}")
|
|
raise typer.Exit(1)
|
|
selected_script = script_type
|
|
else:
|
|
default_script = "ps" if os.name == "nt" else "sh"
|
|
|
|
if _stdin_is_interactive():
|
|
selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script)
|
|
else:
|
|
selected_script = default_script
|
|
|
|
console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}")
|
|
console.print(f"[cyan]Selected script type:[/cyan] {selected_script}")
|
|
|
|
tracker = StepTracker("Initialize Specify Project")
|
|
|
|
sys._specify_tracker_active = True
|
|
|
|
tracker.add("precheck", "Check required tools")
|
|
tracker.complete("precheck", "ok")
|
|
tracker.add("ai-select", "Select coding agent integration")
|
|
tracker.complete("ai-select", f"{selected_ai}")
|
|
tracker.add("script-select", "Select script type")
|
|
tracker.complete("script-select", selected_script)
|
|
|
|
tracker.add("integration", "Install integration")
|
|
tracker.add("shared-infra", "Install shared infrastructure")
|
|
|
|
for key, label in [
|
|
("chmod", "Ensure scripts executable"),
|
|
("constitution", "Constitution setup"),
|
|
("git", "Install git extension"),
|
|
("workflow", "Install bundled workflow"),
|
|
("final", "Finalize"),
|
|
]:
|
|
tracker.add(key, label)
|
|
|
|
git_default_notice = False
|
|
|
|
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
|
|
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
|
try:
|
|
# Integration-based scaffolding
|
|
from .integrations.manifest import IntegrationManifest
|
|
tracker.start("integration")
|
|
manifest = IntegrationManifest(
|
|
resolved_integration.key, project_path, version=get_speckit_version()
|
|
)
|
|
|
|
# Forward all legacy CLI flags to the integration as parsed_options.
|
|
# Integrations receive every option and decide what to use;
|
|
# irrelevant keys are simply ignored by the integration's setup().
|
|
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
|
|
# Parse --integration-options and merge into parsed_options so
|
|
# flags like --skills reach the integration's setup().
|
|
if integration_options:
|
|
extra = _parse_integration_options(resolved_integration, integration_options)
|
|
if extra:
|
|
integration_parsed_options.update(extra)
|
|
|
|
resolved_integration.setup(
|
|
project_path, manifest,
|
|
parsed_options=integration_parsed_options or None,
|
|
script_type=selected_script,
|
|
raw_options=integration_options,
|
|
)
|
|
manifest.save()
|
|
|
|
integration_settings = _with_integration_setting(
|
|
{},
|
|
resolved_integration.key,
|
|
resolved_integration,
|
|
script_type=selected_script,
|
|
raw_options=integration_options,
|
|
parsed_options=integration_parsed_options or None,
|
|
)
|
|
_write_integration_json(
|
|
project_path,
|
|
resolved_integration.key,
|
|
[resolved_integration.key],
|
|
integration_settings,
|
|
)
|
|
|
|
tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key))
|
|
|
|
# Install shared infrastructure (scripts, templates)
|
|
tracker.start("shared-infra")
|
|
_install_shared_infra_or_exit(
|
|
project_path,
|
|
selected_script,
|
|
tracker=tracker,
|
|
force=force,
|
|
invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options),
|
|
)
|
|
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
|
|
|
|
ensure_constitution_from_template(project_path, tracker=tracker)
|
|
|
|
if not no_git:
|
|
tracker.start("git")
|
|
git_messages = []
|
|
git_has_error = False
|
|
# Step 1: Initialize git repo if needed
|
|
if is_git_repo(project_path):
|
|
git_messages.append("existing repo detected")
|
|
elif should_init_git:
|
|
success, error_msg = init_git_repo(project_path, quiet=True)
|
|
if success:
|
|
git_messages.append("initialized")
|
|
else:
|
|
git_has_error = True
|
|
# Sanitize multi-line error_msg to single line for tracker
|
|
if error_msg:
|
|
sanitized = error_msg.replace('\n', ' ').strip()
|
|
git_messages.append(f"init failed: {sanitized[:120]}")
|
|
else:
|
|
git_messages.append("init failed")
|
|
else:
|
|
git_messages.append("git not available")
|
|
# Step 2: Install bundled git extension
|
|
try:
|
|
from .extensions import ExtensionManager
|
|
bundled_path = _locate_bundled_extension("git")
|
|
if bundled_path:
|
|
manager = ExtensionManager(project_path)
|
|
if manager.registry.is_installed("git"):
|
|
git_messages.append("extension already installed")
|
|
else:
|
|
manager.install_from_directory(
|
|
bundled_path, get_speckit_version()
|
|
)
|
|
git_default_notice = True
|
|
git_messages.append("extension installed")
|
|
else:
|
|
git_has_error = True
|
|
git_messages.append("bundled extension not found")
|
|
except Exception as ext_err:
|
|
git_has_error = True
|
|
git_messages.append(
|
|
f"extension install failed during optional git setup: {_cli_error_detail(ext_err)[:120]}"
|
|
)
|
|
summary = "; ".join(git_messages)
|
|
if git_has_error:
|
|
tracker.error("git", summary)
|
|
else:
|
|
tracker.complete("git", summary)
|
|
else:
|
|
tracker.skip("git", "--no-git flag")
|
|
|
|
# Install bundled speckit workflow
|
|
try:
|
|
bundled_wf = _locate_bundled_workflow("speckit")
|
|
if bundled_wf:
|
|
from .workflows.catalog import WorkflowRegistry
|
|
from .workflows.engine import WorkflowDefinition
|
|
wf_registry = WorkflowRegistry(project_path)
|
|
if wf_registry.is_installed("speckit"):
|
|
tracker.complete("workflow", "already installed")
|
|
else:
|
|
import shutil as _shutil
|
|
dest_wf = project_path / ".specify" / "workflows" / "speckit"
|
|
dest_wf.mkdir(parents=True, exist_ok=True)
|
|
_shutil.copy2(
|
|
bundled_wf / "workflow.yml",
|
|
dest_wf / "workflow.yml",
|
|
)
|
|
definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml")
|
|
wf_registry.add("speckit", {
|
|
"name": definition.name,
|
|
"version": definition.version,
|
|
"description": definition.description,
|
|
"source": "bundled",
|
|
})
|
|
tracker.complete("workflow", "speckit installed")
|
|
else:
|
|
tracker.skip("workflow", "bundled workflow not found")
|
|
except Exception as wf_err:
|
|
tracker.error(
|
|
"workflow",
|
|
f"install bundled workflow 'speckit' failed: {_cli_error_detail(wf_err)[:120]}",
|
|
)
|
|
|
|
# Fix permissions after all installs (scripts + extensions)
|
|
ensure_executable_scripts(project_path, tracker=tracker)
|
|
|
|
# Persist the CLI options so later operations (e.g. preset add)
|
|
# can adapt their behaviour without re-scanning the filesystem.
|
|
# Must be saved BEFORE preset install so _get_skills_dir() works.
|
|
init_opts = {
|
|
"ai": selected_ai,
|
|
"integration": resolved_integration.key,
|
|
"branch_numbering": branch_numbering or "sequential",
|
|
"context_file": resolved_integration.context_file,
|
|
"here": here,
|
|
"script": selected_script,
|
|
"speckit_version": get_speckit_version(),
|
|
}
|
|
# Ensure ai_skills is set for SkillsIntegration so downstream
|
|
# tools (extensions, presets) emit SKILL.md overrides correctly.
|
|
# Also set for integrations running in skills mode (e.g. Copilot
|
|
# with --skills).
|
|
from .integrations.base import SkillsIntegration as _SkillsPersist
|
|
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
|
|
init_opts["ai_skills"] = True
|
|
save_init_options(project_path, init_opts)
|
|
|
|
# Install preset if specified
|
|
if preset:
|
|
try:
|
|
from .presets import PresetManager, PresetCatalog, PresetError
|
|
preset_manager = PresetManager(project_path)
|
|
speckit_ver = get_speckit_version()
|
|
|
|
# Try local directory first, then bundled, then catalog
|
|
local_path = Path(preset).resolve()
|
|
if local_path.is_dir() and (local_path / "preset.yml").exists():
|
|
preset_manager.install_from_directory(local_path, speckit_ver)
|
|
else:
|
|
bundled_path = _locate_bundled_preset(preset)
|
|
if bundled_path:
|
|
preset_manager.install_from_directory(bundled_path, speckit_ver)
|
|
else:
|
|
preset_catalog = PresetCatalog(project_path)
|
|
pack_info = preset_catalog.get_pack_info(preset)
|
|
if not pack_info:
|
|
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
|
|
elif pack_info.get("bundled") and not pack_info.get("download_url"):
|
|
from .extensions import REINSTALL_COMMAND
|
|
console.print(
|
|
f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
|
|
f"but could not be found in the installed package."
|
|
)
|
|
console.print(
|
|
"This usually means the spec-kit installation is incomplete or corrupted."
|
|
)
|
|
console.print(f"Try reinstalling: {REINSTALL_COMMAND}")
|
|
else:
|
|
zip_path = None
|
|
try:
|
|
zip_path = preset_catalog.download_pack(preset)
|
|
preset_manager.install_from_zip(zip_path, speckit_ver)
|
|
except PresetError as preset_err:
|
|
_print_cli_warning(
|
|
"install",
|
|
"preset",
|
|
preset,
|
|
preset_err,
|
|
continuing="Continuing without the optional preset.",
|
|
)
|
|
finally:
|
|
if zip_path is not None:
|
|
# Clean up downloaded ZIP to avoid cache accumulation
|
|
try:
|
|
zip_path.unlink(missing_ok=True)
|
|
except OSError:
|
|
# Best-effort cleanup; failure to delete is non-fatal
|
|
pass
|
|
except Exception as preset_err:
|
|
# Optional preset install must not abort project initialization.
|
|
_print_cli_warning(
|
|
"install",
|
|
"preset",
|
|
preset,
|
|
preset_err,
|
|
continuing="Continuing without the optional preset.",
|
|
)
|
|
|
|
tracker.complete("final", "project ready")
|
|
except (typer.Exit, SystemExit):
|
|
raise
|
|
except Exception as e:
|
|
tracker.error("final", str(e))
|
|
console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red"))
|
|
if debug:
|
|
_env_pairs = [
|
|
("Python", sys.version.split()[0]),
|
|
("Platform", sys.platform),
|
|
("CWD", str(Path.cwd())),
|
|
]
|
|
_label_width = max(len(k) for k, _ in _env_pairs)
|
|
env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs]
|
|
console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta"))
|
|
if not here and project_path.exists() and not dir_existed_before:
|
|
shutil.rmtree(project_path)
|
|
raise typer.Exit(1)
|
|
finally:
|
|
pass
|
|
|
|
console.print(tracker.render())
|
|
console.print("\n[bold green]Project ready.[/bold green]")
|
|
|
|
# Agent folder security notice
|
|
agent_config = AGENT_CONFIG.get(selected_ai)
|
|
if agent_config:
|
|
agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"]
|
|
if agent_folder:
|
|
security_notice = Panel(
|
|
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
|
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
|
title="[yellow]Agent Folder Security[/yellow]",
|
|
border_style="yellow",
|
|
padding=(1, 2)
|
|
)
|
|
console.print()
|
|
console.print(security_notice)
|
|
|
|
if ai_deprecation_warning:
|
|
deprecation_notice = Panel(
|
|
ai_deprecation_warning,
|
|
title="[bold red]Deprecation Warning[/bold red]",
|
|
border_style="red",
|
|
padding=(1, 2),
|
|
)
|
|
console.print()
|
|
console.print(deprecation_notice)
|
|
|
|
if git_default_notice:
|
|
default_change_notice = Panel(
|
|
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
|
|
"Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n"
|
|
"Use [bold]specify extension add git[/bold] after init when needed.",
|
|
title="[yellow]Notice: Git Default Changing[/yellow]",
|
|
border_style="yellow",
|
|
padding=(1, 2),
|
|
)
|
|
console.print()
|
|
console.print(default_change_notice)
|
|
|
|
steps_lines = []
|
|
if not here:
|
|
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
|
|
step_num = 2
|
|
else:
|
|
steps_lines.append("1. You're already in the project directory!")
|
|
step_num = 2
|
|
|
|
# Determine skill display mode for the next-steps panel.
|
|
# Skills integrations (codex, claude, kimi, agy, trae, cursor-agent, copilot, devin) should show skill invocation syntax.
|
|
from .integrations.base import SkillsIntegration as _SkillsInt
|
|
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
|
|
|
|
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
|
|
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
|
|
kimi_skill_mode = selected_ai == "kimi"
|
|
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
|
trae_skill_mode = selected_ai == "trae"
|
|
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
|
|
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
|
|
devin_skill_mode = selected_ai == "devin"
|
|
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
|
|
|
|
if codex_skill_mode and not ai_skills:
|
|
# Integration path installed skills; show the helpful notice
|
|
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
|
step_num += 1
|
|
if claude_skill_mode and not ai_skills:
|
|
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
|
|
step_num += 1
|
|
if cursor_agent_skill_mode and not ai_skills:
|
|
steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
|
|
step_num += 1
|
|
if devin_skill_mode:
|
|
steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]")
|
|
step_num += 1
|
|
usage_label = "skills" if native_skill_mode else "slash commands"
|
|
|
|
def _display_cmd(name: str) -> str:
|
|
if codex_skill_mode or agy_skill_mode or trae_skill_mode:
|
|
return f"$speckit-{name}"
|
|
if claude_skill_mode:
|
|
return f"/speckit-{name}"
|
|
if kimi_skill_mode:
|
|
return f"/skill:speckit-{name}"
|
|
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
|
|
return f"/speckit-{name}"
|
|
return f"/speckit.{name}"
|
|
|
|
steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:")
|
|
|
|
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
|
|
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
|
|
steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan")
|
|
steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks")
|
|
steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation")
|
|
|
|
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2))
|
|
console.print()
|
|
console.print(steps_panel)
|
|
|
|
enhancement_intro = (
|
|
"Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
|
if native_skill_mode
|
|
else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
|
)
|
|
enhancement_lines = [
|
|
enhancement_intro,
|
|
"",
|
|
f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)",
|
|
f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])",
|
|
f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])"
|
|
]
|
|
enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands"
|
|
enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1,2))
|
|
console.print()
|
|
console.print(enhancements_panel)
|
|
|
|
@app.command()
|
|
def check():
|
|
"""Check that all required tools are installed."""
|
|
show_banner()
|
|
console.print("[bold]Checking for installed tools...[/bold]\n")
|
|
|
|
tracker = StepTracker("Check Available Tools")
|
|
|
|
tracker.add("git", "Git version control")
|
|
git_ok = check_tool("git", tracker=tracker)
|
|
|
|
agent_results = {}
|
|
for agent_key, agent_config in AGENT_CONFIG.items():
|
|
if agent_key == "generic":
|
|
continue # Generic is not a real agent to check
|
|
agent_name = agent_config["name"]
|
|
requires_cli = agent_config["requires_cli"]
|
|
|
|
tracker.add(agent_key, agent_name)
|
|
|
|
if requires_cli:
|
|
agent_results[agent_key] = check_tool(agent_key, tracker=tracker)
|
|
else:
|
|
# IDE-based agent - skip CLI check and mark as optional
|
|
tracker.skip(agent_key, "IDE-based, no CLI check")
|
|
agent_results[agent_key] = False # Don't count IDE agents as "found"
|
|
|
|
# Check VS Code variants (not in agent config)
|
|
tracker.add("code", "Visual Studio Code")
|
|
check_tool("code", tracker=tracker)
|
|
|
|
tracker.add("code-insiders", "Visual Studio Code Insiders")
|
|
check_tool("code-insiders", tracker=tracker)
|
|
|
|
console.print(tracker.render())
|
|
|
|
console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
|
|
|
|
if not git_ok:
|
|
console.print("[dim]Tip: Install git for repository management[/dim]")
|
|
|
|
if not any(agent_results.values()):
|
|
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
|
|
|
|
|
|
def _feature_capabilities() -> dict[str, bool]:
|
|
"""Return stable local CLI capability flags for humans and agents."""
|
|
return {
|
|
"controlled_multi_install_integrations": True,
|
|
"integration_use_command": True,
|
|
"multi_install_safe_registry_metadata": True,
|
|
"integration_upgrade_command": True,
|
|
"self_check_command": True,
|
|
"workflow_catalog": True,
|
|
"bundled_templates": True,
|
|
}
|
|
|
|
|
|
@app.command()
|
|
def version(
|
|
features: bool = typer.Option(
|
|
False,
|
|
"--features",
|
|
help="Show local CLI feature capabilities.",
|
|
),
|
|
json_output: bool = typer.Option(
|
|
False,
|
|
"--json",
|
|
help="Emit feature capabilities as JSON. Requires --features.",
|
|
),
|
|
):
|
|
"""Display version and system information."""
|
|
import platform
|
|
|
|
cli_version = get_speckit_version()
|
|
|
|
if json_output and not features:
|
|
console.print("[red]Error:[/red] --json requires --features.")
|
|
raise typer.Exit(1)
|
|
|
|
if features:
|
|
capabilities = _feature_capabilities()
|
|
if json_output:
|
|
payload = {"version": cli_version, "features": capabilities}
|
|
console.print(json.dumps(payload, indent=2))
|
|
return
|
|
|
|
console.print(f"Spec Kit CLI: {cli_version}")
|
|
console.print()
|
|
console.print("Features:")
|
|
for key, enabled in capabilities.items():
|
|
label = key.replace("_", " ")
|
|
console.print(f"- {label}: {'yes' if enabled else 'no'}")
|
|
return
|
|
|
|
show_banner()
|
|
|
|
info_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
info_table.add_column("Key", style="cyan", justify="right")
|
|
info_table.add_column("Value", style="white")
|
|
|
|
info_table.add_row("CLI Version", cli_version)
|
|
info_table.add_row("", "")
|
|
info_table.add_row("Python", platform.python_version())
|
|
info_table.add_row("Platform", platform.system())
|
|
info_table.add_row("Architecture", platform.machine())
|
|
info_table.add_row("OS Version", platform.version())
|
|
|
|
panel = Panel(
|
|
info_table,
|
|
title="[bold cyan]Specify CLI Information[/bold cyan]",
|
|
border_style="cyan",
|
|
padding=(1, 2)
|
|
)
|
|
|
|
console.print(panel)
|
|
console.print()
|
|
|
|
app.add_typer(_self_app, name="self")
|
|
|
|
|
|
# ===== Extension Commands =====
|
|
|
|
extension_app = typer.Typer(
|
|
name="extension",
|
|
help="Manage spec-kit extensions",
|
|
add_completion=False,
|
|
)
|
|
app.add_typer(extension_app, name="extension")
|
|
|
|
catalog_app = typer.Typer(
|
|
name="catalog",
|
|
help="Manage extension catalogs",
|
|
add_completion=False,
|
|
)
|
|
extension_app.add_typer(catalog_app, name="catalog")
|
|
|
|
preset_app = typer.Typer(
|
|
name="preset",
|
|
help="Manage spec-kit presets",
|
|
add_completion=False,
|
|
)
|
|
app.add_typer(preset_app, name="preset")
|
|
|
|
preset_catalog_app = typer.Typer(
|
|
name="catalog",
|
|
help="Manage preset catalogs",
|
|
add_completion=False,
|
|
)
|
|
preset_app.add_typer(preset_catalog_app, name="catalog")
|
|
|
|
|
|
# ===== Integration Commands =====
|
|
|
|
integration_app = typer.Typer(
|
|
name="integration",
|
|
help="Manage coding agent integrations",
|
|
add_completion=False,
|
|
)
|
|
app.add_typer(integration_app, name="integration")
|
|
|
|
integration_catalog_app = typer.Typer(
|
|
name="catalog",
|
|
help="Manage integration catalog sources",
|
|
add_completion=False,
|
|
)
|
|
integration_app.add_typer(integration_catalog_app, name="catalog")
|
|
|
|
|
|
def _read_integration_json(project_root: Path) -> dict[str, Any]:
|
|
"""Load ``.specify/integration.json``. Returns normalized state when present.
|
|
|
|
Delegates the parse / schema-guard logic to the shared
|
|
:func:`_try_read_integration_json` helper so the CLI and workflow engine
|
|
cannot drift on validation rules. Each error variant is translated into
|
|
the existing loud-fail UX (console message + ``typer.Exit(1)``).
|
|
"""
|
|
path = project_root / INTEGRATION_JSON
|
|
state, error = _try_read_integration_json(project_root)
|
|
if error is None:
|
|
return state or {}
|
|
if error.kind == "decode":
|
|
console.print(f"[red]Error:[/red] {path} contains invalid JSON or is not valid UTF-8.")
|
|
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
|
|
console.print(f"[dim]Details:[/dim] {error.detail}")
|
|
elif error.kind == "os":
|
|
console.print(f"[red]Error:[/red] Could not read {path}.")
|
|
console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.")
|
|
console.print(f"[dim]Details:[/dim] {error.detail}")
|
|
elif error.kind == "not_object":
|
|
console.print(
|
|
f"[red]Error:[/red] {path} must contain a JSON object, got {error.detail}."
|
|
)
|
|
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
|
|
elif error.kind == "schema_too_new":
|
|
console.print(
|
|
f"[red]Error:[/red] {path} uses integration state schema {error.schema}, "
|
|
f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}."
|
|
)
|
|
console.print("Please upgrade Spec Kit before modifying integrations.")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def _write_integration_json(
|
|
project_root: Path,
|
|
integration_key: str | None,
|
|
installed_integrations: list[str] | None = None,
|
|
integration_settings: dict[str, dict[str, Any]] | None = None,
|
|
) -> None:
|
|
"""Write ``.specify/integration.json`` with legacy-compatible state."""
|
|
_write_integration_json_file(
|
|
project_root,
|
|
version=get_speckit_version(),
|
|
integration_key=integration_key,
|
|
installed_integrations=installed_integrations,
|
|
settings=integration_settings,
|
|
)
|
|
|
|
|
|
def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None:
|
|
"""Clear active integration keys from init-options.json when they match."""
|
|
opts = load_init_options(project_root)
|
|
if opts.get("integration") == integration_key or opts.get("ai") == integration_key:
|
|
opts.pop("integration", None)
|
|
opts.pop("ai", None)
|
|
opts.pop("ai_skills", None)
|
|
opts.pop("context_file", None)
|
|
save_init_options(project_root, opts)
|
|
|
|
|
|
def _remove_integration_json(project_root: Path) -> None:
|
|
"""Remove ``.specify/integration.json`` if it exists."""
|
|
path = project_root / INTEGRATION_JSON
|
|
if path.exists():
|
|
path.unlink()
|
|
|
|
|
|
_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError)
|
|
|
|
|
|
class _SharedTemplateRefreshError(RuntimeError):
|
|
"""Raised when default integration metadata should not be persisted."""
|
|
|
|
|
|
def _normalize_script_type(script_type: str, source: str) -> str:
|
|
"""Normalize and validate a script type from CLI/config sources."""
|
|
normalized = script_type.strip().lower()
|
|
if normalized in SCRIPT_TYPE_CHOICES:
|
|
return normalized
|
|
console.print(
|
|
f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. "
|
|
f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}."
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def _resolve_script_type(project_root: Path, script_type: str | None) -> str:
|
|
"""Resolve the script type from the CLI flag or init-options.json."""
|
|
if script_type:
|
|
return _normalize_script_type(script_type, "--script")
|
|
opts = load_init_options(project_root)
|
|
saved = opts.get("script")
|
|
if isinstance(saved, str) and saved.strip():
|
|
return _normalize_script_type(saved, ".specify/init-options.json")
|
|
return "ps" if os.name == "nt" else "sh"
|
|
|
|
|
|
def _resolve_integration_script_type(
|
|
project_root: Path,
|
|
state: dict[str, Any],
|
|
key: str,
|
|
script_type: str | None = None,
|
|
) -> str:
|
|
"""Resolve script type for an integration, preferring stored settings."""
|
|
if script_type:
|
|
return _normalize_script_type(script_type, "--script")
|
|
|
|
stored = _integration_setting(state, key).get("script")
|
|
if isinstance(stored, str) and stored.strip():
|
|
return _normalize_script_type(stored, f"{INTEGRATION_JSON} integration_settings.{key}.script")
|
|
|
|
return _resolve_script_type(project_root, None)
|
|
|
|
|
|
def _resolve_integration_options(
|
|
integration: Any,
|
|
state: dict[str, Any],
|
|
key: str,
|
|
raw_options: str | None,
|
|
) -> tuple[str | None, dict[str, Any] | None]:
|
|
"""Resolve raw and parsed options for an integration operation."""
|
|
return _resolve_integration_options_impl(
|
|
integration,
|
|
state,
|
|
key,
|
|
raw_options,
|
|
parse_options=_parse_integration_options,
|
|
)
|
|
|
|
|
|
def _set_default_integration(
|
|
project_root: Path,
|
|
state: dict[str, Any],
|
|
key: str,
|
|
integration: Any,
|
|
installed_keys: list[str],
|
|
*,
|
|
script_type: str | None = None,
|
|
raw_options: str | None = None,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
refresh_templates: bool = True,
|
|
refresh_templates_force: bool = False,
|
|
) -> None:
|
|
"""Persist *key* as default and align active runtime metadata."""
|
|
resolved_script = _resolve_integration_script_type(project_root, state, key, script_type)
|
|
settings = _with_integration_setting(
|
|
state,
|
|
key,
|
|
integration,
|
|
script_type=resolved_script,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
|
|
if refresh_templates:
|
|
try:
|
|
_refresh_shared_templates(
|
|
project_root,
|
|
invoke_separator=_invoke_separator_for_integration(
|
|
integration, {"integration_settings": settings}, key, parsed_options
|
|
),
|
|
force=refresh_templates_force,
|
|
)
|
|
except (ValueError, OSError) as exc:
|
|
raise _SharedTemplateRefreshError(
|
|
f"Failed to refresh shared templates for '{key}': {exc}"
|
|
) from exc
|
|
|
|
_write_integration_json(project_root, key, installed_keys, settings)
|
|
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
|
|
|
|
|
|
def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
|
|
try:
|
|
_set_default_integration(*args, **kwargs)
|
|
except _SharedTemplateRefreshError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def _require_specify_project() -> Path:
|
|
"""Return the current project root if it is a spec-kit project, else exit."""
|
|
project_root = Path.cwd()
|
|
if (project_root / ".specify").is_dir():
|
|
return project_root
|
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
|
console.print("Run this command from a spec-kit project root")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@integration_app.command("list")
|
|
def integration_list(
|
|
catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"),
|
|
):
|
|
"""List available integrations and installed status."""
|
|
from .integrations import INTEGRATION_REGISTRY
|
|
|
|
project_root = _require_specify_project()
|
|
current = _read_integration_json(project_root)
|
|
default_key = _default_integration_key(current)
|
|
installed_keys = set(_installed_integration_keys(current))
|
|
|
|
if catalog:
|
|
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
|
|
|
|
ic = IntegrationCatalog(project_root)
|
|
try:
|
|
entries = ic.search()
|
|
except IntegrationCatalogError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
if not entries:
|
|
console.print("[yellow]No integrations found in catalog.[/yellow]")
|
|
return
|
|
|
|
table = Table(title="Integration Catalog")
|
|
table.add_column("ID", style="cyan")
|
|
table.add_column("Name")
|
|
table.add_column("Version")
|
|
table.add_column("Source")
|
|
table.add_column("Status")
|
|
table.add_column("Multi-install Safe")
|
|
|
|
for entry in sorted(entries, key=lambda e: e["id"]):
|
|
eid = entry["id"]
|
|
cat_name = entry.get("_catalog_name", "")
|
|
install_allowed = entry.get("_install_allowed", True)
|
|
if eid == default_key:
|
|
status = "[green]installed (default)[/green]"
|
|
elif eid in installed_keys:
|
|
status = "[green]installed[/green]"
|
|
elif eid in INTEGRATION_REGISTRY:
|
|
status = "built-in"
|
|
elif install_allowed is False:
|
|
status = "discovery-only"
|
|
else:
|
|
status = ""
|
|
safe = ""
|
|
if eid in INTEGRATION_REGISTRY:
|
|
safe = "yes" if getattr(INTEGRATION_REGISTRY[eid], "multi_install_safe", False) else "no"
|
|
table.add_row(
|
|
eid,
|
|
entry.get("name", eid),
|
|
entry.get("version", ""),
|
|
cat_name,
|
|
status,
|
|
safe,
|
|
)
|
|
|
|
console.print(table)
|
|
return
|
|
|
|
table = Table(title="Coding Agent Integrations")
|
|
table.add_column("Key", style="cyan")
|
|
table.add_column("Name")
|
|
table.add_column("Status")
|
|
table.add_column("CLI Required")
|
|
table.add_column("Multi-install Safe")
|
|
|
|
for key in sorted(INTEGRATION_REGISTRY.keys()):
|
|
integration = INTEGRATION_REGISTRY[key]
|
|
cfg = integration.config or {}
|
|
name = cfg.get("name", key)
|
|
requires_cli = cfg.get("requires_cli", False)
|
|
|
|
if key == default_key:
|
|
status = "[green]installed (default)[/green]"
|
|
elif key in installed_keys:
|
|
status = "[green]installed[/green]"
|
|
else:
|
|
status = ""
|
|
|
|
cli_req = "yes" if requires_cli else "no (IDE)"
|
|
safe = "yes" if getattr(integration, "multi_install_safe", False) else "no"
|
|
table.add_row(key, name, status, cli_req, safe)
|
|
|
|
console.print(table)
|
|
|
|
if installed_keys:
|
|
console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]")
|
|
console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]")
|
|
else:
|
|
console.print("\n[yellow]No integration currently installed.[/yellow]")
|
|
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
|
|
|
|
|
|
@integration_app.command("install")
|
|
def integration_install(
|
|
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
|
|
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
|
force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"),
|
|
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
|
):
|
|
"""Install an integration into an existing project."""
|
|
from .integrations import INTEGRATION_REGISTRY, get_integration
|
|
from .integrations.manifest import IntegrationManifest
|
|
|
|
project_root = _require_specify_project()
|
|
integration = get_integration(key)
|
|
if integration is None:
|
|
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
|
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
|
|
console.print(f"Available integrations: {available}")
|
|
raise typer.Exit(1)
|
|
|
|
current = _read_integration_json(project_root)
|
|
default_key = _default_integration_key(current)
|
|
installed_keys = _installed_integration_keys(current)
|
|
|
|
if key in installed_keys:
|
|
console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]")
|
|
if default_key == key:
|
|
console.print("It is already the default integration.")
|
|
else:
|
|
console.print(
|
|
f"To make it the default integration, run "
|
|
f"[cyan]specify integration use {key}[/cyan]."
|
|
)
|
|
console.print(
|
|
f"To refresh its managed files or options, run "
|
|
f"[cyan]specify integration upgrade {key}[/cyan]."
|
|
)
|
|
console.print("No files were changed.")
|
|
raise typer.Exit(0)
|
|
|
|
if installed_keys and not force:
|
|
unsafe_keys = []
|
|
for installed_key in installed_keys:
|
|
installed_integration = get_integration(installed_key)
|
|
if not installed_integration or not getattr(installed_integration, "multi_install_safe", False):
|
|
unsafe_keys.append(installed_key)
|
|
if unsafe_keys or not getattr(integration, "multi_install_safe", False):
|
|
console.print(
|
|
f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}."
|
|
)
|
|
if default_key:
|
|
console.print(f"Default integration: [cyan]{default_key}[/cyan].")
|
|
console.print(
|
|
"Installing multiple integrations is only automatic when all involved "
|
|
"integrations are declared multi-install safe."
|
|
)
|
|
console.print(
|
|
f"To replace the default integration, run "
|
|
f"[cyan]specify integration switch {key}[/cyan]."
|
|
)
|
|
console.print(
|
|
f"To install '{key}' alongside the existing integrations anyway, "
|
|
"retry the same install command with [cyan]--force[/cyan]."
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
selected_script = _resolve_script_type(project_root, script)
|
|
|
|
# Build parsed options from --integration-options so the integration
|
|
# can determine its effective invoke separator before shared infra
|
|
# is installed.
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
integration, current, key, integration_options
|
|
)
|
|
|
|
# Ensure shared infrastructure is present (safe to run unconditionally;
|
|
# _install_shared_infra merges missing files without overwriting).
|
|
infra_integration = integration
|
|
infra_key = key
|
|
infra_parsed = parsed_options
|
|
if default_key:
|
|
default_integration = get_integration(default_key)
|
|
if default_integration is not None:
|
|
infra_integration = default_integration
|
|
infra_key = default_key
|
|
_, infra_parsed = _resolve_integration_options(
|
|
default_integration, current, default_key, None
|
|
)
|
|
_install_shared_infra_or_exit(
|
|
project_root,
|
|
selected_script,
|
|
invoke_separator=_invoke_separator_for_integration(
|
|
infra_integration, current, infra_key, infra_parsed
|
|
),
|
|
)
|
|
if os.name != "nt":
|
|
ensure_executable_scripts(project_root)
|
|
|
|
manifest = IntegrationManifest(
|
|
integration.key, project_root, version=get_speckit_version()
|
|
)
|
|
|
|
try:
|
|
integration.setup(
|
|
project_root, manifest,
|
|
parsed_options=parsed_options,
|
|
script_type=selected_script,
|
|
raw_options=raw_options,
|
|
)
|
|
manifest.save()
|
|
new_installed = _dedupe_integration_keys([*installed_keys, integration.key])
|
|
new_default = default_key or integration.key
|
|
settings = _with_integration_setting(
|
|
current,
|
|
integration.key,
|
|
integration,
|
|
script_type=selected_script,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
_write_integration_json(project_root, new_default, new_installed, settings)
|
|
if new_default == integration.key:
|
|
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
|
|
|
except Exception as exc:
|
|
# Attempt rollback of any files written by setup
|
|
try:
|
|
integration.teardown(project_root, manifest, force=True)
|
|
except Exception as rollback_err:
|
|
# Suppress so the original setup error remains the primary failure
|
|
_print_cli_warning(
|
|
"rollback",
|
|
"integration",
|
|
key,
|
|
rollback_err,
|
|
continuing="The original install failure is still the primary error.",
|
|
)
|
|
if installed_keys:
|
|
_write_integration_json(
|
|
project_root, default_key, installed_keys, _integration_settings(current)
|
|
)
|
|
else:
|
|
_remove_integration_json(project_root)
|
|
console.print(
|
|
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', key)}: "
|
|
f"{_cli_error_detail(exc)}"
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
name = (integration.config or {}).get("name", key)
|
|
console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully")
|
|
if default_key:
|
|
console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]")
|
|
|
|
|
|
def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None:
|
|
"""Parse --integration-options string into a dict matching the integration's declared options.
|
|
|
|
Returns ``None`` when no options are provided.
|
|
"""
|
|
import shlex
|
|
parsed: dict[str, Any] = {}
|
|
tokens = shlex.split(raw_options)
|
|
declared_options = list(integration.options())
|
|
declared = {opt.name.lstrip("-"): opt for opt in declared_options}
|
|
allowed = ", ".join(sorted(opt.name for opt in declared_options))
|
|
i = 0
|
|
while i < len(tokens):
|
|
token = tokens[i]
|
|
if not token.startswith("-"):
|
|
console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.")
|
|
if allowed:
|
|
console.print(f"Allowed options: {allowed}")
|
|
raise typer.Exit(1)
|
|
name = token.lstrip("-")
|
|
value: str | None = None
|
|
# Handle --name=value syntax
|
|
if "=" in name:
|
|
name, value = name.split("=", 1)
|
|
opt = declared.get(name)
|
|
if not opt:
|
|
console.print(f"[red]Error:[/red] Unknown integration option '{token}'.")
|
|
if allowed:
|
|
console.print(f"Allowed options: {allowed}")
|
|
raise typer.Exit(1)
|
|
key = name.replace("-", "_")
|
|
if opt.is_flag:
|
|
if value is not None:
|
|
console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.")
|
|
raise typer.Exit(1)
|
|
parsed[key] = True
|
|
i += 1
|
|
elif value is not None:
|
|
parsed[key] = value
|
|
i += 1
|
|
elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
|
|
parsed[key] = tokens[i + 1]
|
|
i += 2
|
|
else:
|
|
console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.")
|
|
raise typer.Exit(1)
|
|
return parsed or None
|
|
|
|
|
|
def _update_init_options_for_integration(
|
|
project_root: Path,
|
|
integration: Any,
|
|
script_type: str | None = None,
|
|
) -> None:
|
|
"""Update ``init-options.json`` to reflect *integration* as the active one."""
|
|
from .integrations.base import SkillsIntegration
|
|
opts = load_init_options(project_root)
|
|
opts["integration"] = integration.key
|
|
opts["ai"] = integration.key
|
|
opts["context_file"] = integration.context_file
|
|
if script_type:
|
|
opts["script"] = script_type
|
|
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
|
|
opts["ai_skills"] = True
|
|
else:
|
|
opts.pop("ai_skills", None)
|
|
save_init_options(project_root, opts)
|
|
|
|
|
|
@integration_app.command("use")
|
|
def integration_use(
|
|
key: str = typer.Argument(help="Installed integration key to make the default"),
|
|
force: bool = typer.Option(False, "--force", help="Overwrite managed shared templates while changing the default"),
|
|
):
|
|
"""Set the default integration without uninstalling other integrations."""
|
|
from .integrations import get_integration
|
|
|
|
project_root = _require_specify_project()
|
|
current = _read_integration_json(project_root)
|
|
installed_keys = _installed_integration_keys(current)
|
|
if key not in installed_keys:
|
|
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
|
|
if installed_keys:
|
|
console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}")
|
|
else:
|
|
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
|
|
raise typer.Exit(1)
|
|
|
|
integration = get_integration(key)
|
|
if integration is None:
|
|
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
|
raise typer.Exit(1)
|
|
|
|
raw_options, parsed_options = _resolve_integration_options(integration, current, key, None)
|
|
_set_default_integration_or_exit(
|
|
project_root,
|
|
current,
|
|
key,
|
|
integration,
|
|
installed_keys,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
refresh_templates_force=force,
|
|
)
|
|
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")
|
|
|
|
|
|
@integration_app.command("uninstall")
|
|
def integration_uninstall(
|
|
key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"),
|
|
force: bool = typer.Option(False, "--force", help="Remove files even if modified"),
|
|
):
|
|
"""Uninstall an integration, safely preserving modified files."""
|
|
from .integrations import get_integration
|
|
from .integrations.manifest import IntegrationManifest
|
|
|
|
project_root = _require_specify_project()
|
|
current = _read_integration_json(project_root)
|
|
default_key = _default_integration_key(current)
|
|
installed_keys = _installed_integration_keys(current)
|
|
|
|
if key is None:
|
|
if not default_key:
|
|
console.print("[yellow]No integration is currently installed.[/yellow]")
|
|
raise typer.Exit(0)
|
|
key = default_key
|
|
|
|
if key not in installed_keys:
|
|
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
|
|
raise typer.Exit(1)
|
|
|
|
integration = get_integration(key)
|
|
|
|
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
|
|
if not manifest_path.exists():
|
|
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]")
|
|
remaining = [installed for installed in installed_keys if installed != key]
|
|
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
|
|
if remaining:
|
|
if default_key == key and new_default and (new_integration := get_integration(new_default)):
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
new_integration, current, new_default, None
|
|
)
|
|
_set_default_integration_or_exit(
|
|
project_root,
|
|
current,
|
|
new_default,
|
|
new_integration,
|
|
remaining,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
else:
|
|
_write_integration_json(
|
|
project_root, new_default, remaining, _integration_settings(current)
|
|
)
|
|
else:
|
|
_remove_integration_json(project_root)
|
|
if default_key == key:
|
|
_clear_init_options_for_integration(project_root, key)
|
|
raise typer.Exit(0)
|
|
|
|
try:
|
|
manifest = IntegrationManifest.load(key, project_root)
|
|
except _MANIFEST_READ_ERRORS as exc:
|
|
console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.")
|
|
console.print(f"Manifest: {manifest_path}")
|
|
console.print(
|
|
f"To recover, delete the unreadable manifest, run "
|
|
f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, "
|
|
f"then run [cyan]specify integration install {key}[/cyan] to regenerate."
|
|
)
|
|
console.print(f"[dim]Details:[/dim] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
removed, skipped = manifest.uninstall(project_root, force=force)
|
|
|
|
# Remove managed context section from the agent context file
|
|
if integration:
|
|
integration.remove_context_section(project_root)
|
|
|
|
remaining = [installed for installed in installed_keys if installed != key]
|
|
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
|
|
if remaining:
|
|
if default_key == key and new_default and (new_integration := get_integration(new_default)):
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
new_integration, current, new_default, None
|
|
)
|
|
_set_default_integration_or_exit(
|
|
project_root,
|
|
current,
|
|
new_default,
|
|
new_integration,
|
|
remaining,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
else:
|
|
_write_integration_json(
|
|
project_root, new_default, remaining, _integration_settings(current)
|
|
)
|
|
else:
|
|
_remove_integration_json(project_root)
|
|
|
|
if default_key == key:
|
|
_clear_init_options_for_integration(project_root, key)
|
|
|
|
name = (integration.config or {}).get("name", key) if integration else key
|
|
console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled")
|
|
if removed:
|
|
console.print(f" Removed {len(removed)} file(s)")
|
|
if skipped:
|
|
console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:")
|
|
for path in skipped:
|
|
rel = _display_project_path(project_root, path)
|
|
console.print(f" {rel}")
|
|
|
|
|
|
@integration_app.command("switch")
|
|
def integration_switch(
|
|
target: str = typer.Argument(help="Integration key to switch to"),
|
|
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
|
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
|
|
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
|
|
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
|
|
):
|
|
"""Switch from the current integration to a different one."""
|
|
from .integrations import INTEGRATION_REGISTRY, get_integration
|
|
from .integrations.manifest import IntegrationManifest
|
|
|
|
project_root = _require_specify_project()
|
|
target_integration = get_integration(target)
|
|
if target_integration is None:
|
|
console.print(f"[red]Error:[/red] Unknown integration '{target}'")
|
|
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
|
|
console.print(f"Available integrations: {available}")
|
|
raise typer.Exit(1)
|
|
|
|
current = _read_integration_json(project_root)
|
|
installed_keys = _installed_integration_keys(current)
|
|
installed_key = _default_integration_key(current)
|
|
|
|
if installed_key == target:
|
|
if integration_options is not None:
|
|
console.print(
|
|
"[red]Error:[/red] --integration-options cannot be used when switching "
|
|
"to an already installed integration."
|
|
)
|
|
console.print(
|
|
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
|
|
"to update managed files/options."
|
|
)
|
|
raise typer.Exit(1)
|
|
if force:
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
target_integration, current, target, None
|
|
)
|
|
_set_default_integration_or_exit(
|
|
project_root,
|
|
current,
|
|
target,
|
|
target_integration,
|
|
installed_keys,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
refresh_templates_force=True,
|
|
)
|
|
console.print(
|
|
f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; "
|
|
"managed shared templates refreshed."
|
|
)
|
|
raise typer.Exit(0)
|
|
console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]")
|
|
raise typer.Exit(0)
|
|
|
|
if target in installed_keys:
|
|
if integration_options is not None:
|
|
console.print(
|
|
"[red]Error:[/red] --integration-options cannot be used when switching "
|
|
"to an already installed integration."
|
|
)
|
|
console.print(
|
|
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
|
|
f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]."
|
|
)
|
|
raise typer.Exit(1)
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
target_integration, current, target, None
|
|
)
|
|
_set_default_integration_or_exit(
|
|
project_root,
|
|
current,
|
|
target,
|
|
target_integration,
|
|
installed_keys,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
refresh_templates_force=force,
|
|
)
|
|
console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].")
|
|
raise typer.Exit(0)
|
|
|
|
selected_script = _resolve_script_type(project_root, script)
|
|
|
|
# Phase 1: Uninstall current integration (if any)
|
|
if installed_key:
|
|
current_integration = get_integration(installed_key)
|
|
manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json"
|
|
|
|
if current_integration and manifest_path.exists():
|
|
console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]")
|
|
try:
|
|
old_manifest = IntegrationManifest.load(installed_key, project_root)
|
|
except _MANIFEST_READ_ERRORS as exc:
|
|
console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}")
|
|
console.print(f"[dim]{exc}[/dim]")
|
|
console.print(
|
|
f"To recover, delete the unreadable manifest at {manifest_path}, "
|
|
f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry."
|
|
)
|
|
raise typer.Exit(1)
|
|
removed, skipped = old_manifest.uninstall(project_root, force=force)
|
|
current_integration.remove_context_section(project_root)
|
|
if removed:
|
|
console.print(f" Removed {len(removed)} file(s)")
|
|
if skipped:
|
|
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
|
|
elif not current_integration and manifest_path.exists():
|
|
# Integration removed from registry but manifest exists — use manifest-only uninstall
|
|
console.print(f"Uninstalling unknown integration '{installed_key}' via manifest")
|
|
try:
|
|
old_manifest = IntegrationManifest.load(installed_key, project_root)
|
|
removed, skipped = old_manifest.uninstall(project_root, force=force)
|
|
if removed:
|
|
console.print(f" Removed {len(removed)} file(s)")
|
|
if skipped:
|
|
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
|
|
except _MANIFEST_READ_ERRORS as exc:
|
|
console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}")
|
|
else:
|
|
console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.")
|
|
console.print(
|
|
f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, "
|
|
f"then retry [cyan]specify integration switch {target}[/cyan]."
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
# Unregister extension commands for the old agent so they don't
|
|
# remain as orphans in the old agent's directory.
|
|
try:
|
|
from .extensions import ExtensionManager
|
|
|
|
ext_mgr = ExtensionManager(project_root)
|
|
ext_mgr.unregister_agent_artifacts(installed_key)
|
|
except Exception as ext_err:
|
|
_print_cli_warning(
|
|
"clean up extension artifacts for",
|
|
"integration",
|
|
installed_key,
|
|
ext_err,
|
|
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
|
|
)
|
|
|
|
# Clear metadata so a failed Phase 2 doesn't leave stale references
|
|
installed_keys = [installed for installed in installed_keys if installed != installed_key]
|
|
_clear_init_options_for_integration(project_root, installed_key)
|
|
if installed_keys:
|
|
fallback_key = installed_keys[0]
|
|
fallback_integration = get_integration(fallback_key)
|
|
if fallback_integration is not None:
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
fallback_integration, current, fallback_key, None
|
|
)
|
|
_set_default_integration_or_exit(
|
|
project_root,
|
|
current,
|
|
fallback_key,
|
|
fallback_integration,
|
|
installed_keys,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
else:
|
|
_write_integration_json(
|
|
project_root, fallback_key, installed_keys, _integration_settings(current)
|
|
)
|
|
else:
|
|
_remove_integration_json(project_root)
|
|
current = _read_integration_json(project_root)
|
|
|
|
# Build parsed options from --integration-options so the integration
|
|
# can determine its effective invoke separator before shared infra
|
|
# is installed.
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
target_integration, current, target, integration_options
|
|
)
|
|
|
|
# Refresh shared infrastructure to the current CLI version. Switching
|
|
# integrations is exactly when stale vendored shared scripts (e.g.
|
|
# update-agent-context.sh that pre-dates the target integration's
|
|
# supported-agent list) would silently break the new integration.
|
|
#
|
|
# Use refresh_managed=True so only files that match their previously
|
|
# recorded hash are overwritten — user customizations are detected via
|
|
# hash divergence and preserved with a warning. Pass
|
|
# --refresh-shared-infra to overwrite customizations as well. See #2293.
|
|
_install_shared_infra_or_exit(
|
|
project_root,
|
|
selected_script,
|
|
force=refresh_shared_infra,
|
|
refresh_managed=True,
|
|
invoke_separator=_invoke_separator_for_integration(
|
|
target_integration, current, target, parsed_options
|
|
),
|
|
refresh_hint=(
|
|
"To overwrite customizations, re-run with "
|
|
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
|
|
),
|
|
)
|
|
if os.name != "nt":
|
|
ensure_executable_scripts(project_root)
|
|
|
|
# Phase 2: Install target integration
|
|
console.print(f"Installing integration: [cyan]{target}[/cyan]")
|
|
manifest = IntegrationManifest(
|
|
target_integration.key, project_root, version=get_speckit_version()
|
|
)
|
|
|
|
try:
|
|
target_integration.setup(
|
|
project_root, manifest,
|
|
parsed_options=parsed_options,
|
|
script_type=selected_script,
|
|
raw_options=raw_options,
|
|
)
|
|
manifest.save()
|
|
_set_default_integration(
|
|
project_root,
|
|
current,
|
|
target_integration.key,
|
|
target_integration,
|
|
_dedupe_integration_keys([*installed_keys, target_integration.key]),
|
|
script_type=selected_script,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
|
|
# Re-register extension commands for the new agent so that
|
|
# previously-installed extensions are available in the new integration.
|
|
try:
|
|
from .extensions import ExtensionManager
|
|
|
|
ext_mgr = ExtensionManager(project_root)
|
|
ext_mgr.register_enabled_extensions_for_agent(target)
|
|
except Exception as ext_err:
|
|
_print_cli_warning(
|
|
"register extension artifacts for",
|
|
"integration",
|
|
target,
|
|
ext_err,
|
|
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
|
|
)
|
|
|
|
except Exception as exc:
|
|
# Attempt rollback of any files written by setup
|
|
try:
|
|
target_integration.teardown(project_root, manifest, force=True)
|
|
except Exception as rollback_err:
|
|
# Suppress so the original setup error remains the primary failure
|
|
_print_cli_warning(
|
|
"rollback",
|
|
"integration",
|
|
target,
|
|
rollback_err,
|
|
continuing="The original switch failure is still the primary error.",
|
|
)
|
|
if installed_keys:
|
|
fallback_key = installed_keys[0]
|
|
fallback_integration = get_integration(fallback_key)
|
|
if fallback_integration is not None:
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
fallback_integration, current, fallback_key, None
|
|
)
|
|
try:
|
|
_set_default_integration(
|
|
project_root,
|
|
current,
|
|
fallback_key,
|
|
fallback_integration,
|
|
installed_keys,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
except _SharedTemplateRefreshError as restore_err:
|
|
console.print(
|
|
f"[yellow]Warning:[/yellow] Failed to restore default "
|
|
f"integration '{fallback_key}': {restore_err}"
|
|
)
|
|
else:
|
|
_write_integration_json(
|
|
project_root, fallback_key, installed_keys, _integration_settings(current)
|
|
)
|
|
else:
|
|
_remove_integration_json(project_root)
|
|
console.print(
|
|
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', target)} "
|
|
f"during switch: {_cli_error_detail(exc)}"
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
name = (target_integration.config or {}).get("name", target)
|
|
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
|
|
|
|
|
|
@integration_app.command("upgrade")
|
|
def integration_upgrade(
|
|
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
|
|
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
|
|
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
|
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
|
|
):
|
|
"""Upgrade an integration by reinstalling with diff-aware file handling.
|
|
|
|
Compares manifest hashes to detect locally modified files and
|
|
blocks the upgrade unless --force is used.
|
|
"""
|
|
from .integrations import get_integration
|
|
from .integrations.manifest import IntegrationManifest
|
|
|
|
project_root = _require_specify_project()
|
|
current = _read_integration_json(project_root)
|
|
installed_key = _default_integration_key(current)
|
|
installed_keys = _installed_integration_keys(current)
|
|
|
|
if key is None:
|
|
if not installed_key:
|
|
console.print("[yellow]No integration is currently installed.[/yellow]")
|
|
raise typer.Exit(0)
|
|
key = installed_key
|
|
|
|
if key not in installed_keys:
|
|
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
|
|
raise typer.Exit(1)
|
|
|
|
integration = get_integration(key)
|
|
if integration is None:
|
|
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
|
raise typer.Exit(1)
|
|
|
|
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
|
|
if not manifest_path.exists():
|
|
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]")
|
|
console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.")
|
|
raise typer.Exit(0)
|
|
|
|
try:
|
|
old_manifest = IntegrationManifest.load(key, project_root)
|
|
except _MANIFEST_READ_ERRORS as exc:
|
|
console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
# Detect modified files via manifest hashes
|
|
modified = old_manifest.check_modified()
|
|
if modified and not force:
|
|
console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:")
|
|
for rel in modified:
|
|
console.print(f" {rel}")
|
|
console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.")
|
|
raise typer.Exit(1)
|
|
|
|
selected_script = _resolve_integration_script_type(project_root, current, key, script)
|
|
|
|
# Build parsed options from --integration-options so the integration
|
|
# can determine its effective invoke separator before shared infra
|
|
# is installed.
|
|
raw_options, parsed_options = _resolve_integration_options(
|
|
integration, current, key, integration_options
|
|
)
|
|
|
|
# Ensure shared infrastructure is up to date; --force overwrites existing files.
|
|
infra_integration = integration
|
|
infra_key = key
|
|
infra_parsed = parsed_options
|
|
if installed_key and installed_key != key:
|
|
default_integration = get_integration(installed_key)
|
|
if default_integration is not None:
|
|
infra_integration = default_integration
|
|
infra_key = installed_key
|
|
_, infra_parsed = _resolve_integration_options(
|
|
default_integration, current, installed_key, None
|
|
)
|
|
_install_shared_infra_or_exit(
|
|
project_root,
|
|
selected_script,
|
|
force=force,
|
|
invoke_separator=_invoke_separator_for_integration(
|
|
infra_integration, current, infra_key, infra_parsed
|
|
),
|
|
)
|
|
if os.name != "nt":
|
|
ensure_executable_scripts(project_root)
|
|
|
|
# Phase 1: Install new files (overwrites existing; old-only files remain)
|
|
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
|
new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version())
|
|
|
|
try:
|
|
integration.setup(
|
|
project_root,
|
|
new_manifest,
|
|
parsed_options=parsed_options,
|
|
script_type=selected_script,
|
|
raw_options=raw_options,
|
|
)
|
|
settings = _with_integration_setting(
|
|
current,
|
|
key,
|
|
integration,
|
|
script_type=selected_script,
|
|
raw_options=raw_options,
|
|
parsed_options=parsed_options,
|
|
)
|
|
if installed_key == key:
|
|
try:
|
|
_refresh_shared_templates(
|
|
project_root,
|
|
invoke_separator=_invoke_separator_for_integration(
|
|
integration, {"integration_settings": settings}, key, parsed_options
|
|
),
|
|
force=force,
|
|
)
|
|
except (ValueError, OSError) as exc:
|
|
raise _SharedTemplateRefreshError(
|
|
f"Failed to refresh shared templates for '{key}': {exc}"
|
|
) from exc
|
|
new_manifest.save()
|
|
_write_integration_json(project_root, installed_key, installed_keys, settings)
|
|
if installed_key == key:
|
|
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
|
except Exception as exc:
|
|
# Don't teardown — setup overwrites in-place, so teardown would
|
|
# delete files that were working before the upgrade. Just report.
|
|
console.print(f"[red]Error:[/red] Failed to {_cli_phase_label('upgrade', 'integration', key)}.")
|
|
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
|
|
console.print("[yellow]The previous integration files may still be in place.[/yellow]")
|
|
raise typer.Exit(1)
|
|
|
|
# Phase 2: Remove stale files from old manifest that are not in the new one
|
|
old_files = old_manifest.files
|
|
new_files = new_manifest.files
|
|
stale_keys = set(old_files) - set(new_files)
|
|
if stale_keys:
|
|
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
|
|
stale_manifest._files = {k: old_files[k] for k in stale_keys}
|
|
stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
|
|
if stale_removed:
|
|
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
|
|
|
|
name = (integration.config or {}).get("name", key)
|
|
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
|
|
|
|
|
|
# ===== Integration catalog discovery commands =====
|
|
#
|
|
# These commands mirror the workflow catalog CLI shape:
|
|
# - `search` / `info` for discovery over the active catalog stack
|
|
# - `catalog list/add/remove` for managing catalog sources
|
|
#
|
|
# They deliberately do NOT add `integration add/remove/enable/disable/
|
|
# set-priority`: integrations are single-active (install / uninstall / switch),
|
|
# not additive like extensions and presets.
|
|
|
|
|
|
@integration_app.command("search")
|
|
def integration_search(
|
|
query: Optional[str] = typer.Argument(None, help="Search query (optional)"),
|
|
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
|
|
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
|
|
):
|
|
"""Search for integrations in the active catalog stack."""
|
|
from .integrations import INTEGRATION_REGISTRY
|
|
from .integrations.catalog import (
|
|
IntegrationCatalog,
|
|
IntegrationCatalogError,
|
|
IntegrationValidationError,
|
|
)
|
|
|
|
project_root = _require_specify_project()
|
|
integration_config = _read_integration_json(project_root)
|
|
installed_key = integration_config.get("integration")
|
|
catalog = IntegrationCatalog(project_root)
|
|
|
|
try:
|
|
results = catalog.search(query=query, tag=tag, author=author)
|
|
except IntegrationValidationError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
console.print(
|
|
"\nTip: Check the configuration file path shown above for invalid catalog configuration "
|
|
"(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
|
|
)
|
|
raise typer.Exit(1)
|
|
except IntegrationCatalogError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
|
|
console.print(
|
|
"\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid "
|
|
"catalog URL, or unset it to use the configured catalog files "
|
|
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
|
|
)
|
|
else:
|
|
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
|
|
raise typer.Exit(1)
|
|
|
|
if not results:
|
|
console.print("\n[yellow]No integrations found matching criteria[/yellow]")
|
|
if query or tag or author:
|
|
console.print("\nTry:")
|
|
console.print(" • Broader search terms")
|
|
console.print(" • Remove filters")
|
|
console.print(" • specify integration search (show all)")
|
|
return
|
|
|
|
console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
|
|
for integ in sorted(results, key=lambda e: e.get("id", "")):
|
|
iid = integ.get("id", "?")
|
|
name = integ.get("name", iid)
|
|
version = integ.get("version", "?")
|
|
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
|
|
desc = integ.get("description", "")
|
|
if desc:
|
|
console.print(f" {desc}")
|
|
|
|
console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
|
|
tags = integ.get("tags", [])
|
|
if isinstance(tags, list) and tags:
|
|
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
|
|
|
|
cat_name = integ.get("_catalog_name", "")
|
|
install_allowed = integ.get("_install_allowed", True)
|
|
if cat_name:
|
|
if install_allowed:
|
|
console.print(f" [dim]Catalog:[/dim] {cat_name}")
|
|
else:
|
|
console.print(
|
|
f" [dim]Catalog:[/dim] {cat_name} "
|
|
"[yellow](discovery only — not installable)[/yellow]"
|
|
)
|
|
|
|
if iid == installed_key:
|
|
console.print("\n [green]✓ Installed[/green] (currently active)")
|
|
elif iid in INTEGRATION_REGISTRY:
|
|
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
|
|
elif install_allowed:
|
|
console.print(
|
|
"\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs "
|
|
"can be installed with 'specify integration install'."
|
|
)
|
|
else:
|
|
console.print(
|
|
f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'."
|
|
)
|
|
console.print()
|
|
|
|
|
|
@integration_app.command("info")
|
|
def integration_info(
|
|
integration_id: str = typer.Argument(..., help="Integration ID"),
|
|
):
|
|
"""Show catalog details for a single integration."""
|
|
from .integrations import INTEGRATION_REGISTRY
|
|
from .integrations.catalog import (
|
|
IntegrationCatalog,
|
|
IntegrationCatalogError,
|
|
IntegrationValidationError,
|
|
)
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = IntegrationCatalog(project_root)
|
|
installed_key = _read_integration_json(project_root).get("integration")
|
|
|
|
try:
|
|
info = catalog.get_integration_info(integration_id)
|
|
except IntegrationCatalogError as exc:
|
|
info = None
|
|
# Keep the live exception so the fallback branch below can give
|
|
# different guidance for local-config vs. network failures.
|
|
catalog_error: Optional[IntegrationCatalogError] = exc
|
|
else:
|
|
catalog_error = None
|
|
|
|
if info:
|
|
name = info.get("name", integration_id)
|
|
version = info.get("version", "?")
|
|
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
|
|
if info.get("description"):
|
|
console.print(f" {info['description']}")
|
|
console.print()
|
|
|
|
console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
|
|
if info.get("license"):
|
|
console.print(f" [dim]License:[/dim] {info['license']}")
|
|
|
|
tags = info.get("tags", [])
|
|
if isinstance(tags, list) and tags:
|
|
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
|
|
|
|
cat_name = info.get("_catalog_name", "")
|
|
install_allowed = info.get("_install_allowed", True)
|
|
if cat_name:
|
|
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
|
|
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")
|
|
|
|
if info.get("repository"):
|
|
console.print(f" [dim]Repository:[/dim] {info['repository']}")
|
|
|
|
if integration_id == installed_key:
|
|
console.print("\n [green]✓ Installed[/green] (currently active)")
|
|
elif integration_id in INTEGRATION_REGISTRY:
|
|
console.print("\n [dim]Built-in integration (not currently active)[/dim]")
|
|
return
|
|
|
|
if integration_id in INTEGRATION_REGISTRY:
|
|
integration = INTEGRATION_REGISTRY[integration_id]
|
|
cfg = integration.config or {}
|
|
name = cfg.get("name", integration_id)
|
|
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})")
|
|
console.print(" [dim]Built-in integration (not listed in catalog)[/dim]")
|
|
if integration_id == installed_key:
|
|
console.print("\n [green]✓ Installed[/green] (currently active)")
|
|
if catalog_error:
|
|
console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}")
|
|
return
|
|
|
|
if catalog_error:
|
|
console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}")
|
|
if isinstance(catalog_error, IntegrationValidationError):
|
|
console.print(
|
|
"\nCheck the configuration file path shown above "
|
|
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), "
|
|
"or use a built-in integration ID directly."
|
|
)
|
|
elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
|
|
console.print(
|
|
"\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, "
|
|
"or unset it to use the configured catalog files, or use a built-in integration ID directly."
|
|
)
|
|
else:
|
|
console.print("\nTry again when online, or use a built-in integration ID directly.")
|
|
else:
|
|
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
|
|
console.print("\nTry: specify integration search")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@integration_catalog_app.command("list")
|
|
def integration_catalog_list():
|
|
"""List configured integration catalog sources."""
|
|
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = IntegrationCatalog(project_root)
|
|
env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip()
|
|
|
|
try:
|
|
if env_override:
|
|
project_configs = None
|
|
configs = catalog.get_catalog_configs()
|
|
else:
|
|
project_configs = catalog.get_project_catalog_configs()
|
|
configs = project_configs if project_configs is not None else catalog.get_catalog_configs()
|
|
except IntegrationCatalogError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n")
|
|
if env_override:
|
|
console.print(
|
|
" SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files."
|
|
)
|
|
console.print(
|
|
" Project/user catalog sources are not active while the env override is set.\n"
|
|
)
|
|
console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n")
|
|
elif project_configs is None:
|
|
console.print(" No project-level catalog sources configured.\n")
|
|
console.print("[bold]Active catalog sources (non-removable here):[/bold]\n")
|
|
else:
|
|
console.print("[bold]Project catalog sources (removable):[/bold]\n")
|
|
|
|
for i, cfg in enumerate(configs):
|
|
install_status = (
|
|
"[green]install allowed[/green]"
|
|
if cfg.get("install_allowed")
|
|
else "[yellow]discovery only[/yellow]"
|
|
)
|
|
raw_name = cfg.get("name")
|
|
display_name = str(raw_name).strip() if raw_name is not None else ""
|
|
if not display_name:
|
|
display_name = f"catalog-{i + 1}"
|
|
if env_override or project_configs is None:
|
|
console.print(f" - [bold]{display_name}[/bold] — {install_status}")
|
|
else:
|
|
console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}")
|
|
console.print(f" {cfg.get('url', '')}")
|
|
if cfg.get("description"):
|
|
console.print(f" [dim]{cfg['description']}[/dim]")
|
|
console.print()
|
|
|
|
|
|
@integration_catalog_app.command("add")
|
|
def integration_catalog_add(
|
|
url: str = typer.Argument(
|
|
...,
|
|
help=(
|
|
"Catalog URL to add (HTTPS required, except http://localhost, "
|
|
"http://127.0.0.1, or http://[::1] for local testing)"
|
|
),
|
|
),
|
|
name: Optional[str] = typer.Option(None, "--name", help="Catalog name"),
|
|
):
|
|
"""Add an integration catalog source to the project config."""
|
|
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = IntegrationCatalog(project_root)
|
|
|
|
# Normalize once here so the success message reflects what was actually
|
|
# stored. ``IntegrationCatalog.add_catalog`` strips again defensively.
|
|
normalized_url = url.strip()
|
|
|
|
try:
|
|
catalog.add_catalog(normalized_url, name)
|
|
except IntegrationCatalogError as exc:
|
|
# Covers both URL validation (base class) and config-file validation
|
|
# (IntegrationValidationError subclass).
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
console.print(f"[green]✓[/green] Catalog source added: {normalized_url}")
|
|
|
|
|
|
@integration_catalog_app.command("remove")
|
|
def integration_catalog_remove(
|
|
index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
|
|
):
|
|
"""Remove an integration catalog source by 0-based index."""
|
|
from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = IntegrationCatalog(project_root)
|
|
|
|
try:
|
|
removed_name = catalog.remove_catalog(index)
|
|
except IntegrationCatalogError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")
|
|
|
|
|
|
# ===== Preset Commands =====
|
|
|
|
|
|
@preset_app.command("list")
|
|
def preset_list():
|
|
"""List installed presets."""
|
|
from .presets import PresetManager
|
|
|
|
project_root = _require_specify_project()
|
|
manager = PresetManager(project_root)
|
|
installed = manager.list_installed()
|
|
|
|
if not installed:
|
|
console.print("[yellow]No presets installed.[/yellow]")
|
|
console.print("\nInstall a preset with:")
|
|
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
|
|
return
|
|
|
|
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
|
|
for pack in installed:
|
|
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
|
pri = pack.get('priority', 10)
|
|
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
|
|
console.print(f" {pack['description']}")
|
|
if pack.get("tags"):
|
|
tags_str = ", ".join(pack["tags"])
|
|
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
|
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
|
|
console.print()
|
|
|
|
|
|
@preset_app.command("add")
|
|
def preset_add(
|
|
preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
|
|
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
|
|
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
|
|
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
|
):
|
|
"""Install a preset."""
|
|
from .presets import (
|
|
PresetManager,
|
|
PresetCatalog,
|
|
PresetError,
|
|
PresetValidationError,
|
|
PresetCompatibilityError,
|
|
)
|
|
|
|
project_root = _require_specify_project()
|
|
# Validate priority
|
|
if priority < 1:
|
|
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
raise typer.Exit(1)
|
|
|
|
manager = PresetManager(project_root)
|
|
speckit_version = get_speckit_version()
|
|
|
|
try:
|
|
if dev:
|
|
dev_path = Path(dev).resolve()
|
|
if not dev_path.exists():
|
|
console.print(f"[red]Error:[/red] Directory not found: {dev}")
|
|
raise typer.Exit(1)
|
|
|
|
console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...")
|
|
manifest = manager.install_from_directory(dev_path, speckit_version, priority)
|
|
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
|
|
|
elif from_url:
|
|
# Validate URL scheme before downloading
|
|
from urllib.parse import urlparse as _urlparse
|
|
_parsed = _urlparse(from_url)
|
|
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
|
|
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
|
|
raise typer.Exit(1)
|
|
|
|
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
|
import urllib.request
|
|
import urllib.error
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
zip_path = Path(tmpdir) / "preset.zip"
|
|
try:
|
|
from specify_cli.authentication.http import open_url as _open_url
|
|
|
|
with _open_url(from_url, timeout=60) as response:
|
|
zip_path.write_bytes(response.read())
|
|
except urllib.error.URLError as e:
|
|
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
|
raise typer.Exit(1)
|
|
|
|
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
|
|
|
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
|
|
|
elif preset_id:
|
|
# Try bundled preset first, then catalog
|
|
bundled_path = _locate_bundled_preset(preset_id)
|
|
if bundled_path:
|
|
console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...")
|
|
manifest = manager.install_from_directory(bundled_path, speckit_version, priority)
|
|
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
|
else:
|
|
catalog = PresetCatalog(project_root)
|
|
pack_info = catalog.get_pack_info(preset_id)
|
|
|
|
if not pack_info:
|
|
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog")
|
|
raise typer.Exit(1)
|
|
|
|
# Bundled presets should have been caught above; if we reach
|
|
# here the bundled files are missing from the installation.
|
|
if pack_info.get("bundled") and not pack_info.get("download_url"):
|
|
from .extensions import REINSTALL_COMMAND
|
|
console.print(
|
|
f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit "
|
|
f"but could not be found in the installed package."
|
|
)
|
|
console.print(
|
|
"\nThis usually means the spec-kit installation is incomplete or corrupted."
|
|
)
|
|
console.print("Try reinstalling spec-kit:")
|
|
console.print(f" {REINSTALL_COMMAND}")
|
|
raise typer.Exit(1)
|
|
|
|
if not pack_info.get("_install_allowed", True):
|
|
catalog_name = pack_info.get("_catalog_name", "unknown")
|
|
console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
|
|
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
|
|
raise typer.Exit(1)
|
|
|
|
console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...")
|
|
|
|
try:
|
|
zip_path = catalog.download_pack(preset_id)
|
|
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
|
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
|
finally:
|
|
if 'zip_path' in locals() and zip_path.exists():
|
|
zip_path.unlink(missing_ok=True)
|
|
else:
|
|
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
|
|
raise typer.Exit(1)
|
|
|
|
except PresetCompatibilityError as e:
|
|
console.print(f"[red]Compatibility Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
except PresetValidationError as e:
|
|
console.print(f"[red]Validation Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
except PresetError as e:
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@preset_app.command("remove")
|
|
def preset_remove(
|
|
preset_id: str = typer.Argument(..., help="Preset ID to remove"),
|
|
):
|
|
"""Remove an installed preset."""
|
|
from .presets import PresetManager
|
|
|
|
project_root = _require_specify_project()
|
|
manager = PresetManager(project_root)
|
|
|
|
if not manager.registry.is_installed(preset_id):
|
|
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
|
|
raise typer.Exit(1)
|
|
|
|
if manager.remove(preset_id):
|
|
console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully")
|
|
else:
|
|
console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@preset_app.command("search")
|
|
def preset_search(
|
|
query: str = typer.Argument(None, help="Search query"),
|
|
tag: str = typer.Option(None, "--tag", help="Filter by tag"),
|
|
author: str = typer.Option(None, "--author", help="Filter by author"),
|
|
):
|
|
"""Search for presets in the catalog."""
|
|
from .presets import PresetCatalog, PresetError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = PresetCatalog(project_root)
|
|
|
|
try:
|
|
results = catalog.search(query=query, tag=tag, author=author)
|
|
except PresetError as e:
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
|
|
if not results:
|
|
console.print("[yellow]No presets found matching your criteria.[/yellow]")
|
|
return
|
|
|
|
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
|
|
for pack in results:
|
|
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
|
|
console.print(f" {pack.get('description', '')}")
|
|
if pack.get("tags"):
|
|
tags_str = ", ".join(pack["tags"])
|
|
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
|
console.print()
|
|
|
|
|
|
@preset_app.command("resolve")
|
|
def preset_resolve(
|
|
template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"),
|
|
):
|
|
"""Show which template will be resolved for a given name."""
|
|
from .presets import PresetResolver
|
|
|
|
project_root = _require_specify_project()
|
|
resolver = PresetResolver(project_root)
|
|
layers = resolver.collect_all_layers(template_name)
|
|
|
|
if layers:
|
|
# Use the highest-priority layer for display because the final output
|
|
# may be composed and may not map to resolve_with_source()'s single path.
|
|
display_layer = layers[0]
|
|
console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
|
|
console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
|
|
|
|
has_composition = (
|
|
layers[0]["strategy"] != "replace"
|
|
and any(layer["strategy"] != "replace" for layer in layers)
|
|
)
|
|
if has_composition:
|
|
# Verify composition is actually possible
|
|
try:
|
|
composed = resolver.resolve_content(template_name)
|
|
except Exception as exc:
|
|
composed = None
|
|
console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
|
|
if composed is None:
|
|
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
|
|
else:
|
|
console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]")
|
|
console.print("\n [bold]Composition chain:[/bold]")
|
|
# Compute the effective base: first replace layer scanning from
|
|
# highest priority (matching resolve_content top-down logic).
|
|
# Only show layers from the base upward (lower layers are ignored).
|
|
effective_base_idx = None
|
|
for idx, lyr in enumerate(layers):
|
|
if lyr["strategy"] == "replace":
|
|
effective_base_idx = idx
|
|
break
|
|
# Show only contributing layers (base and above)
|
|
if effective_base_idx is not None:
|
|
contributing = layers[:effective_base_idx + 1]
|
|
else:
|
|
contributing = layers
|
|
for i, layer in enumerate(reversed(contributing)):
|
|
strategy_label = layer["strategy"]
|
|
if strategy_label == "replace" and i == 0:
|
|
strategy_label = "base"
|
|
console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}")
|
|
else:
|
|
# No layers found — fall back to resolve_with_source for non-composition cases
|
|
result = resolver.resolve_with_source(template_name)
|
|
if result:
|
|
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
|
|
console.print(f" [dim](from: {result['source']})[/dim]")
|
|
else:
|
|
console.print(f" [yellow]{template_name}[/yellow]: not found")
|
|
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
|
|
|
|
|
@preset_app.command("info")
|
|
def preset_info(
|
|
preset_id: str = typer.Argument(..., help="Preset ID to get info about"),
|
|
):
|
|
"""Show detailed information about a preset."""
|
|
from .extensions import normalize_priority
|
|
from .presets import PresetCatalog, PresetManager, PresetError
|
|
|
|
project_root = _require_specify_project()
|
|
# Check if installed locally first
|
|
manager = PresetManager(project_root)
|
|
local_pack = manager.get_pack(preset_id)
|
|
|
|
if local_pack:
|
|
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
|
|
console.print(f" ID: {local_pack.id}")
|
|
console.print(f" Version: {local_pack.version}")
|
|
console.print(f" Description: {local_pack.description}")
|
|
if local_pack.author:
|
|
console.print(f" Author: {local_pack.author}")
|
|
if local_pack.tags:
|
|
console.print(f" Tags: {', '.join(local_pack.tags)}")
|
|
console.print(f" Templates: {len(local_pack.templates)}")
|
|
for tmpl in local_pack.templates:
|
|
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
|
|
repo = local_pack.data.get("preset", {}).get("repository")
|
|
if repo:
|
|
console.print(f" Repository: {repo}")
|
|
license_val = local_pack.data.get("preset", {}).get("license")
|
|
if license_val:
|
|
console.print(f" License: {license_val}")
|
|
console.print("\n [green]Status: installed[/green]")
|
|
# Get priority from registry
|
|
pack_metadata = manager.registry.get(preset_id)
|
|
priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None)
|
|
console.print(f" [dim]Priority:[/dim] {priority}")
|
|
console.print()
|
|
return
|
|
|
|
# Fall back to catalog
|
|
catalog = PresetCatalog(project_root)
|
|
try:
|
|
pack_info = catalog.get_pack_info(preset_id)
|
|
except PresetError:
|
|
pack_info = None
|
|
|
|
if not pack_info:
|
|
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)")
|
|
raise typer.Exit(1)
|
|
|
|
console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n")
|
|
console.print(f" ID: {pack_info['id']}")
|
|
console.print(f" Version: {pack_info.get('version', '?')}")
|
|
console.print(f" Description: {pack_info.get('description', '')}")
|
|
if pack_info.get("author"):
|
|
console.print(f" Author: {pack_info['author']}")
|
|
if pack_info.get("tags"):
|
|
console.print(f" Tags: {', '.join(pack_info['tags'])}")
|
|
if pack_info.get("repository"):
|
|
console.print(f" Repository: {pack_info['repository']}")
|
|
if pack_info.get("license"):
|
|
console.print(f" License: {pack_info['license']}")
|
|
console.print("\n [yellow]Status: not installed[/yellow]")
|
|
console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]")
|
|
console.print()
|
|
|
|
|
|
@preset_app.command("set-priority")
|
|
def preset_set_priority(
|
|
preset_id: str = typer.Argument(help="Preset ID"),
|
|
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
|
|
):
|
|
"""Set the resolution priority of an installed preset."""
|
|
from .presets import PresetManager
|
|
|
|
project_root = _require_specify_project()
|
|
# Validate priority
|
|
if priority < 1:
|
|
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
raise typer.Exit(1)
|
|
|
|
manager = PresetManager(project_root)
|
|
|
|
# Check if preset is installed
|
|
if not manager.registry.is_installed(preset_id):
|
|
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
|
|
raise typer.Exit(1)
|
|
|
|
# Get current metadata
|
|
metadata = manager.registry.get(preset_id)
|
|
if metadata is None or not isinstance(metadata, dict):
|
|
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
|
|
raise typer.Exit(1)
|
|
|
|
from .extensions import normalize_priority
|
|
raw_priority = metadata.get("priority")
|
|
# Only skip if the stored value is already a valid int equal to requested priority
|
|
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
|
if isinstance(raw_priority, int) and raw_priority == priority:
|
|
console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]")
|
|
raise typer.Exit(0)
|
|
|
|
old_priority = normalize_priority(raw_priority)
|
|
|
|
# Update priority
|
|
manager.registry.update(preset_id, {"priority": priority})
|
|
|
|
console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}")
|
|
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
|
|
|
|
|
@preset_app.command("enable")
|
|
def preset_enable(
|
|
preset_id: str = typer.Argument(help="Preset ID to enable"),
|
|
):
|
|
"""Enable a disabled preset."""
|
|
from .presets import PresetManager
|
|
|
|
project_root = _require_specify_project()
|
|
manager = PresetManager(project_root)
|
|
|
|
# Check if preset is installed
|
|
if not manager.registry.is_installed(preset_id):
|
|
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
|
|
raise typer.Exit(1)
|
|
|
|
# Get current metadata
|
|
metadata = manager.registry.get(preset_id)
|
|
if metadata is None or not isinstance(metadata, dict):
|
|
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
|
|
raise typer.Exit(1)
|
|
|
|
if metadata.get("enabled", True):
|
|
console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]")
|
|
raise typer.Exit(0)
|
|
|
|
# Enable the preset
|
|
manager.registry.update(preset_id, {"enabled": True})
|
|
|
|
console.print(f"[green]✓[/green] Preset '{preset_id}' enabled")
|
|
console.print("\nTemplates from this preset will now be included in resolution.")
|
|
console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]")
|
|
|
|
|
|
@preset_app.command("disable")
|
|
def preset_disable(
|
|
preset_id: str = typer.Argument(help="Preset ID to disable"),
|
|
):
|
|
"""Disable a preset without removing it."""
|
|
from .presets import PresetManager
|
|
|
|
project_root = _require_specify_project()
|
|
manager = PresetManager(project_root)
|
|
|
|
# Check if preset is installed
|
|
if not manager.registry.is_installed(preset_id):
|
|
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
|
|
raise typer.Exit(1)
|
|
|
|
# Get current metadata
|
|
metadata = manager.registry.get(preset_id)
|
|
if metadata is None or not isinstance(metadata, dict):
|
|
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
|
|
raise typer.Exit(1)
|
|
|
|
if not metadata.get("enabled", True):
|
|
console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]")
|
|
raise typer.Exit(0)
|
|
|
|
# Disable the preset
|
|
manager.registry.update(preset_id, {"enabled": False})
|
|
|
|
console.print(f"[green]✓[/green] Preset '{preset_id}' disabled")
|
|
console.print("\nTemplates from this preset will be skipped during resolution.")
|
|
console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]")
|
|
console.print(f"To re-enable: specify preset enable {preset_id}")
|
|
|
|
|
|
# ===== Preset Catalog Commands =====
|
|
|
|
|
|
@preset_catalog_app.command("list")
|
|
def preset_catalog_list():
|
|
"""List all active preset catalogs."""
|
|
from .presets import PresetCatalog, PresetValidationError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = PresetCatalog(project_root)
|
|
|
|
try:
|
|
active_catalogs = catalog.get_active_catalogs()
|
|
except PresetValidationError as e:
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
|
|
console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
|
|
for entry in active_catalogs:
|
|
install_str = (
|
|
"[green]install allowed[/green]"
|
|
if entry.install_allowed
|
|
else "[yellow]discovery only[/yellow]"
|
|
)
|
|
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
|
|
if entry.description:
|
|
console.print(f" {entry.description}")
|
|
console.print(f" URL: {entry.url}")
|
|
console.print(f" Install: {install_str}")
|
|
console.print()
|
|
|
|
config_path = project_root / ".specify" / "preset-catalogs.yml"
|
|
user_config_path = Path.home() / ".specify" / "preset-catalogs.yml"
|
|
if os.environ.get("SPECKIT_PRESET_CATALOG_URL"):
|
|
console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]")
|
|
else:
|
|
try:
|
|
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
|
|
except PresetValidationError:
|
|
proj_loaded = False
|
|
if proj_loaded:
|
|
console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]")
|
|
else:
|
|
try:
|
|
user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
|
|
except PresetValidationError:
|
|
user_loaded = False
|
|
if user_loaded:
|
|
console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]")
|
|
else:
|
|
console.print("[dim]Using built-in default catalog stack.[/dim]")
|
|
console.print(
|
|
"[dim]Add .specify/preset-catalogs.yml to customize.[/dim]"
|
|
)
|
|
|
|
|
|
@preset_catalog_app.command("add")
|
|
def preset_catalog_add(
|
|
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
|
|
name: str = typer.Option(..., "--name", help="Catalog name"),
|
|
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
|
|
install_allowed: bool = typer.Option(
|
|
False, "--install-allowed/--no-install-allowed",
|
|
help="Allow presets from this catalog to be installed",
|
|
),
|
|
description: str = typer.Option("", "--description", help="Description of the catalog"),
|
|
):
|
|
"""Add a catalog to .specify/preset-catalogs.yml."""
|
|
from .presets import PresetCatalog, PresetValidationError
|
|
|
|
project_root = _require_specify_project()
|
|
specify_dir = project_root / ".specify"
|
|
|
|
# Validate URL
|
|
tmp_catalog = PresetCatalog(project_root)
|
|
try:
|
|
tmp_catalog._validate_catalog_url(url)
|
|
except PresetValidationError as e:
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
|
|
config_path = specify_dir / "preset-catalogs.yml"
|
|
|
|
# Load existing config
|
|
if config_path.exists():
|
|
try:
|
|
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
except Exception as e:
|
|
config_label = _display_project_path(project_root, config_path)
|
|
console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}")
|
|
raise typer.Exit(1)
|
|
else:
|
|
config = {}
|
|
|
|
catalogs = config.get("catalogs", [])
|
|
if not isinstance(catalogs, list):
|
|
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
|
raise typer.Exit(1)
|
|
|
|
# Check for duplicate name
|
|
for existing in catalogs:
|
|
if isinstance(existing, dict) and existing.get("name") == name:
|
|
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
|
|
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
|
|
raise typer.Exit(1)
|
|
|
|
catalogs.append({
|
|
"name": name,
|
|
"url": url,
|
|
"priority": priority,
|
|
"install_allowed": install_allowed,
|
|
"description": description,
|
|
})
|
|
|
|
config["catalogs"] = catalogs
|
|
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
|
|
install_label = "install allowed" if install_allowed else "discovery only"
|
|
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
|
console.print(f" URL: {url}")
|
|
console.print(f" Priority: {priority}")
|
|
console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}")
|
|
|
|
|
|
@preset_catalog_app.command("remove")
|
|
def preset_catalog_remove(
|
|
name: str = typer.Argument(help="Catalog name to remove"),
|
|
):
|
|
"""Remove a catalog from .specify/preset-catalogs.yml."""
|
|
project_root = _require_specify_project()
|
|
specify_dir = project_root / ".specify"
|
|
|
|
config_path = specify_dir / "preset-catalogs.yml"
|
|
if not config_path.exists():
|
|
console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.")
|
|
raise typer.Exit(1)
|
|
|
|
try:
|
|
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
except Exception:
|
|
console.print("[red]Error:[/red] Failed to read preset catalog config.")
|
|
raise typer.Exit(1)
|
|
|
|
catalogs = config.get("catalogs", [])
|
|
if not isinstance(catalogs, list):
|
|
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
|
raise typer.Exit(1)
|
|
original_count = len(catalogs)
|
|
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
|
|
|
|
if len(catalogs) == original_count:
|
|
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
|
|
raise typer.Exit(1)
|
|
|
|
config["catalogs"] = catalogs
|
|
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
|
|
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
|
if not catalogs:
|
|
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
|
|
|
|
|
# ===== Extension Commands =====
|
|
|
|
|
|
def _resolve_installed_extension(
|
|
argument: str,
|
|
installed_extensions: list,
|
|
command_name: str = "command",
|
|
allow_not_found: bool = False,
|
|
) -> tuple[Optional[str], Optional[str]]:
|
|
"""Resolve an extension argument (ID or display name) to an installed extension.
|
|
|
|
Args:
|
|
argument: Extension ID or display name provided by user
|
|
installed_extensions: List of installed extension dicts from manager.list_installed()
|
|
command_name: Name of the command for error messages (e.g., "enable", "disable")
|
|
allow_not_found: If True, return (None, None) when not found instead of raising
|
|
|
|
Returns:
|
|
Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found
|
|
|
|
Raises:
|
|
typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous
|
|
"""
|
|
from rich.table import Table
|
|
|
|
# First, try exact ID match
|
|
for ext in installed_extensions:
|
|
if ext["id"] == argument:
|
|
return (ext["id"], ext["name"])
|
|
|
|
# If not found by ID, try display name match
|
|
name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()]
|
|
|
|
if len(name_matches) == 1:
|
|
# Unique display-name match
|
|
return (name_matches[0]["id"], name_matches[0]["name"])
|
|
elif len(name_matches) > 1:
|
|
# Ambiguous display-name match
|
|
console.print(
|
|
f"[red]Error:[/red] Extension name '{argument}' is ambiguous. "
|
|
"Multiple installed extensions share this name:"
|
|
)
|
|
table = Table(title="Matching extensions")
|
|
table.add_column("ID", style="cyan", no_wrap=True)
|
|
table.add_column("Name", style="white")
|
|
table.add_column("Version", style="green")
|
|
for ext in name_matches:
|
|
table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", "")))
|
|
console.print(table)
|
|
console.print("\nPlease rerun using the extension ID:")
|
|
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
|
|
raise typer.Exit(1)
|
|
else:
|
|
# No match by ID or display name
|
|
if allow_not_found:
|
|
return (None, None)
|
|
console.print(f"[red]Error:[/red] Extension '{argument}' is not installed")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def _resolve_catalog_extension(
|
|
argument: str,
|
|
catalog,
|
|
command_name: str = "info",
|
|
) -> tuple[Optional[dict], Optional[Exception]]:
|
|
"""Resolve an extension argument (ID or display name) from the catalog.
|
|
|
|
Args:
|
|
argument: Extension ID or display name provided by user
|
|
catalog: ExtensionCatalog instance
|
|
command_name: Name of the command for error messages
|
|
|
|
Returns:
|
|
Tuple of (extension_info, catalog_error)
|
|
- If found: (ext_info_dict, None)
|
|
- If catalog error: (None, error)
|
|
- If not found: (None, None)
|
|
"""
|
|
from rich.table import Table
|
|
from .extensions import ExtensionError
|
|
|
|
try:
|
|
# First try by ID
|
|
ext_info = catalog.get_extension_info(argument)
|
|
if ext_info:
|
|
return (ext_info, None)
|
|
|
|
# Try by display name - search using argument as query, then filter for exact match
|
|
search_results = catalog.search(query=argument)
|
|
name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()]
|
|
|
|
if len(name_matches) == 1:
|
|
return (name_matches[0], None)
|
|
elif len(name_matches) > 1:
|
|
# Ambiguous display-name match in catalog
|
|
console.print(
|
|
f"[red]Error:[/red] Extension name '{argument}' is ambiguous. "
|
|
"Multiple catalog extensions share this name:"
|
|
)
|
|
table = Table(title="Matching extensions")
|
|
table.add_column("ID", style="cyan", no_wrap=True)
|
|
table.add_column("Name", style="white")
|
|
table.add_column("Version", style="green")
|
|
table.add_column("Catalog", style="dim")
|
|
for ext in name_matches:
|
|
table.add_row(
|
|
ext.get("id", ""),
|
|
ext.get("name", ""),
|
|
str(ext.get("version", "")),
|
|
ext.get("_catalog_name", ""),
|
|
)
|
|
console.print(table)
|
|
console.print("\nPlease rerun using the extension ID:")
|
|
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
|
|
raise typer.Exit(1)
|
|
|
|
# Not found
|
|
return (None, None)
|
|
|
|
except ExtensionError as e:
|
|
return (None, e)
|
|
|
|
|
|
@extension_app.command("list")
|
|
def extension_list(
|
|
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
|
|
all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"),
|
|
):
|
|
"""List installed extensions."""
|
|
from .extensions import ExtensionManager
|
|
|
|
project_root = _require_specify_project()
|
|
manager = ExtensionManager(project_root)
|
|
installed = manager.list_installed()
|
|
|
|
if not installed and not (available or all_extensions):
|
|
console.print("[yellow]No extensions installed.[/yellow]")
|
|
console.print("\nInstall an extension with:")
|
|
console.print(" specify extension add <extension-name>")
|
|
return
|
|
|
|
if installed:
|
|
console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n")
|
|
|
|
for ext in installed:
|
|
status_icon = "✓" if ext["enabled"] else "✗"
|
|
status_color = "green" if ext["enabled"] else "red"
|
|
|
|
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
|
|
console.print(f" [dim]{ext['id']}[/dim]")
|
|
console.print(f" {ext['description']}")
|
|
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
|
|
console.print()
|
|
|
|
if available or all_extensions:
|
|
console.print("\nInstall an extension:")
|
|
console.print(" [cyan]specify extension add <name>[/cyan]")
|
|
|
|
|
|
@catalog_app.command("list")
|
|
def catalog_list():
|
|
"""List all active extension catalogs."""
|
|
from .extensions import ExtensionCatalog, ValidationError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = ExtensionCatalog(project_root)
|
|
|
|
try:
|
|
active_catalogs = catalog.get_active_catalogs()
|
|
except ValidationError as e:
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
|
|
console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n")
|
|
for entry in active_catalogs:
|
|
install_str = (
|
|
"[green]install allowed[/green]"
|
|
if entry.install_allowed
|
|
else "[yellow]discovery only[/yellow]"
|
|
)
|
|
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
|
|
if entry.description:
|
|
console.print(f" {entry.description}")
|
|
console.print(f" URL: {entry.url}")
|
|
console.print(f" Install: {install_str}")
|
|
console.print()
|
|
|
|
config_path = project_root / ".specify" / "extension-catalogs.yml"
|
|
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
|
|
if os.environ.get("SPECKIT_CATALOG_URL"):
|
|
console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]")
|
|
else:
|
|
try:
|
|
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
|
|
except ValidationError:
|
|
proj_loaded = False
|
|
if proj_loaded:
|
|
console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]")
|
|
else:
|
|
try:
|
|
user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
|
|
except ValidationError:
|
|
user_loaded = False
|
|
if user_loaded:
|
|
console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]")
|
|
else:
|
|
console.print("[dim]Using built-in default catalog stack.[/dim]")
|
|
console.print(
|
|
"[dim]Add .specify/extension-catalogs.yml to customize.[/dim]"
|
|
)
|
|
|
|
|
|
@catalog_app.command("add")
|
|
def catalog_add(
|
|
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
|
|
name: str = typer.Option(..., "--name", help="Catalog name"),
|
|
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
|
|
install_allowed: bool = typer.Option(
|
|
False, "--install-allowed/--no-install-allowed",
|
|
help="Allow extensions from this catalog to be installed",
|
|
),
|
|
description: str = typer.Option("", "--description", help="Description of the catalog"),
|
|
):
|
|
"""Add a catalog to .specify/extension-catalogs.yml."""
|
|
from .extensions import ExtensionCatalog, ValidationError
|
|
|
|
project_root = _require_specify_project()
|
|
specify_dir = project_root / ".specify"
|
|
|
|
# Validate URL
|
|
tmp_catalog = ExtensionCatalog(project_root)
|
|
try:
|
|
tmp_catalog._validate_catalog_url(url)
|
|
except ValidationError as e:
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
|
|
config_path = specify_dir / "extension-catalogs.yml"
|
|
|
|
# Load existing config
|
|
if config_path.exists():
|
|
try:
|
|
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
except Exception as e:
|
|
config_label = _display_project_path(project_root, config_path)
|
|
console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}")
|
|
raise typer.Exit(1)
|
|
else:
|
|
config = {}
|
|
|
|
catalogs = config.get("catalogs", [])
|
|
if not isinstance(catalogs, list):
|
|
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
|
raise typer.Exit(1)
|
|
|
|
# Check for duplicate name
|
|
for existing in catalogs:
|
|
if isinstance(existing, dict) and existing.get("name") == name:
|
|
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
|
|
console.print("Use 'specify extension catalog remove' first, or choose a different name.")
|
|
raise typer.Exit(1)
|
|
|
|
catalogs.append({
|
|
"name": name,
|
|
"url": url,
|
|
"priority": priority,
|
|
"install_allowed": install_allowed,
|
|
"description": description,
|
|
})
|
|
|
|
config["catalogs"] = catalogs
|
|
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
|
|
install_label = "install allowed" if install_allowed else "discovery only"
|
|
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
|
console.print(f" URL: {url}")
|
|
console.print(f" Priority: {priority}")
|
|
console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}")
|
|
|
|
|
|
@catalog_app.command("remove")
|
|
def catalog_remove(
|
|
name: str = typer.Argument(help="Catalog name to remove"),
|
|
):
|
|
"""Remove a catalog from .specify/extension-catalogs.yml."""
|
|
project_root = _require_specify_project()
|
|
specify_dir = project_root / ".specify"
|
|
|
|
config_path = specify_dir / "extension-catalogs.yml"
|
|
if not config_path.exists():
|
|
console.print("[red]Error:[/red] No catalog config found. Nothing to remove.")
|
|
raise typer.Exit(1)
|
|
|
|
try:
|
|
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
except Exception:
|
|
console.print("[red]Error:[/red] Failed to read catalog config.")
|
|
raise typer.Exit(1)
|
|
|
|
catalogs = config.get("catalogs", [])
|
|
if not isinstance(catalogs, list):
|
|
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
|
raise typer.Exit(1)
|
|
original_count = len(catalogs)
|
|
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
|
|
|
|
if len(catalogs) == original_count:
|
|
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
|
|
raise typer.Exit(1)
|
|
|
|
config["catalogs"] = catalogs
|
|
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
|
|
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
|
if not catalogs:
|
|
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
|
|
|
|
|
@extension_app.command("add")
|
|
def extension_add(
|
|
extension: str = typer.Argument(help="Extension name or path"),
|
|
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
|
|
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
|
|
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
|
):
|
|
"""Install an extension."""
|
|
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND
|
|
|
|
project_root = _require_specify_project()
|
|
# Validate priority
|
|
if priority < 1:
|
|
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
raise typer.Exit(1)
|
|
|
|
manager = ExtensionManager(project_root)
|
|
speckit_version = get_speckit_version()
|
|
|
|
try:
|
|
with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
|
|
if dev:
|
|
# Install from local directory
|
|
source_path = Path(extension).expanduser().resolve()
|
|
if not source_path.exists():
|
|
console.print(f"[red]Error:[/red] Directory not found: {source_path}")
|
|
raise typer.Exit(1)
|
|
|
|
if not (source_path / "extension.yml").exists():
|
|
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
|
|
raise typer.Exit(1)
|
|
|
|
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
|
|
|
|
elif from_url:
|
|
# Install from URL (ZIP file)
|
|
import urllib.request
|
|
import urllib.error
|
|
from urllib.parse import urlparse
|
|
|
|
# Validate URL
|
|
parsed = urlparse(from_url)
|
|
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
|
|
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
|
console.print("[red]Error:[/red] URL must use HTTPS for security.")
|
|
console.print("HTTP is only allowed for localhost URLs.")
|
|
raise typer.Exit(1)
|
|
|
|
# Warn about untrusted sources
|
|
console.print("[yellow]Warning:[/yellow] Installing from external URL.")
|
|
console.print("Only install extensions from sources you trust.\n")
|
|
console.print(f"Downloading from {from_url}...")
|
|
|
|
# Download ZIP to temp location
|
|
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
|
|
download_dir.mkdir(parents=True, exist_ok=True)
|
|
zip_path = download_dir / f"{extension}-url-download.zip"
|
|
|
|
try:
|
|
from specify_cli.authentication.http import open_url as _open_url
|
|
|
|
with _open_url(from_url, timeout=60) as response:
|
|
zip_data = response.read()
|
|
zip_path.write_bytes(zip_data)
|
|
|
|
# Install from downloaded ZIP
|
|
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
|
except urllib.error.URLError as e:
|
|
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
|
|
raise typer.Exit(1)
|
|
finally:
|
|
# Clean up downloaded ZIP
|
|
if zip_path.exists():
|
|
zip_path.unlink()
|
|
|
|
else:
|
|
# Try bundled extensions first (shipped with spec-kit)
|
|
bundled_path = _locate_bundled_extension(extension)
|
|
if bundled_path is not None:
|
|
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
|
|
else:
|
|
# Install from catalog (also resolves display names to IDs)
|
|
catalog = ExtensionCatalog(project_root)
|
|
|
|
# Check if extension exists in catalog (supports both ID and display name)
|
|
ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add")
|
|
if catalog_error:
|
|
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
|
|
raise typer.Exit(1)
|
|
if not ext_info:
|
|
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
|
|
console.print("\nSearch available extensions:")
|
|
console.print(" specify extension search")
|
|
raise typer.Exit(1)
|
|
|
|
# If catalog resolved a display name to an ID, check bundled again
|
|
resolved_id = ext_info['id']
|
|
if resolved_id != extension:
|
|
bundled_path = _locate_bundled_extension(resolved_id)
|
|
if bundled_path is not None:
|
|
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
|
|
|
|
if bundled_path is None:
|
|
# Bundled extensions without a download URL must come from the local package
|
|
if ext_info.get("bundled") and not ext_info.get("download_url"):
|
|
console.print(
|
|
f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit "
|
|
f"but could not be found in the installed package."
|
|
)
|
|
console.print(
|
|
"\nThis usually means the spec-kit installation is incomplete or corrupted."
|
|
)
|
|
console.print("Try reinstalling spec-kit:")
|
|
console.print(f" {REINSTALL_COMMAND}")
|
|
raise typer.Exit(1)
|
|
|
|
# Enforce install_allowed policy
|
|
if not ext_info.get("_install_allowed", True):
|
|
catalog_name = ext_info.get("_catalog_name", "community")
|
|
console.print(
|
|
f"[red]Error:[/red] '{extension}' is available in the "
|
|
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
|
|
)
|
|
console.print(
|
|
f"\nTo enable installation, add '{extension}' to an approved catalog "
|
|
f"(install_allowed: true) in .specify/extension-catalogs.yml."
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
# Download extension ZIP (use resolved ID, not original argument which may be display name)
|
|
extension_id = ext_info['id']
|
|
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
|
|
zip_path = catalog.download_extension(extension_id)
|
|
|
|
try:
|
|
# Install from downloaded ZIP
|
|
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
|
finally:
|
|
# Clean up downloaded ZIP
|
|
if zip_path.exists():
|
|
zip_path.unlink()
|
|
|
|
console.print("\n[green]✓[/green] Extension installed successfully!")
|
|
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
|
|
console.print(f" {manifest.description}")
|
|
|
|
for warning in manifest.warnings:
|
|
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")
|
|
|
|
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
|
|
for cmd in manifest.commands:
|
|
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
|
|
|
|
# Report agent skills registration
|
|
reg_meta = manager.registry.get(manifest.id)
|
|
reg_skills = reg_meta.get("registered_skills", []) if reg_meta else []
|
|
# Normalize to guard against corrupted registry entries
|
|
if not isinstance(reg_skills, list):
|
|
reg_skills = []
|
|
if reg_skills:
|
|
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
|
|
|
|
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
|
|
console.print(f" Check: .specify/extensions/{manifest.id}/")
|
|
|
|
except ValidationError as e:
|
|
console.print(f"\n[red]Validation Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
except CompatibilityError as e:
|
|
console.print(f"\n[red]Compatibility Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
except ExtensionError as e:
|
|
console.print(f"\n[red]Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@extension_app.command("remove")
|
|
def extension_remove(
|
|
extension: str = typer.Argument(help="Extension ID or name to remove"),
|
|
keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"),
|
|
force: bool = typer.Option(False, "--force", help="Skip confirmation"),
|
|
):
|
|
"""Uninstall an extension."""
|
|
from .extensions import ExtensionManager
|
|
|
|
project_root = _require_specify_project()
|
|
manager = ExtensionManager(project_root)
|
|
|
|
# Resolve extension ID from argument (handles ambiguous names)
|
|
installed = manager.list_installed()
|
|
extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
|
|
|
|
# Get extension info for command and skill counts
|
|
ext_manifest = manager.get_extension(extension_id)
|
|
reg_meta = manager.registry.get(extension_id)
|
|
# Derive cmd_count from the registry's registered_commands (includes aliases)
|
|
# rather than from the manifest (primary commands only). Use max() across
|
|
# agents to get the per-agent count; sum() would double-count since users
|
|
# think in logical commands, not per-agent file counts.
|
|
# Use get() without a default so we can distinguish "key missing" (fall back
|
|
# to manifest) from "key present but empty dict" (zero commands registered).
|
|
registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None
|
|
if isinstance(registered_commands, dict):
|
|
cmd_count = max(
|
|
(len(v) for v in registered_commands.values() if isinstance(v, list)),
|
|
default=0,
|
|
)
|
|
else:
|
|
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
|
|
raw_skills = reg_meta.get("registered_skills") if reg_meta else None
|
|
skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0
|
|
|
|
# Confirm removal
|
|
if not force:
|
|
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
|
console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent")
|
|
if skill_count:
|
|
console.print(f" • {skill_count} agent skill(s)")
|
|
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")
|
|
if not keep_config:
|
|
console.print(" • Config files (will be backed up)")
|
|
console.print()
|
|
|
|
confirm = typer.confirm("Continue?")
|
|
if not confirm:
|
|
console.print("Cancelled")
|
|
raise typer.Exit(0)
|
|
|
|
# Remove extension
|
|
success = manager.remove(extension_id, keep_config=keep_config)
|
|
|
|
if success:
|
|
console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully")
|
|
if keep_config:
|
|
console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/")
|
|
else:
|
|
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/")
|
|
console.print(f"\nTo reinstall: specify extension add {extension_id}")
|
|
else:
|
|
console.print("[red]Error:[/red] Failed to remove extension")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@extension_app.command("search")
|
|
def extension_search(
|
|
query: str = typer.Argument(None, help="Search query (optional)"),
|
|
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
|
|
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
|
|
verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"),
|
|
):
|
|
"""Search for available extensions in catalog."""
|
|
from .extensions import ExtensionCatalog, ExtensionError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = ExtensionCatalog(project_root)
|
|
|
|
try:
|
|
console.print("🔍 Searching extension catalog...")
|
|
results = catalog.search(query=query, tag=tag, author=author, verified_only=verified)
|
|
|
|
if not results:
|
|
console.print("\n[yellow]No extensions found matching criteria[/yellow]")
|
|
if query or tag or author or verified:
|
|
console.print("\nTry:")
|
|
console.print(" • Broader search terms")
|
|
console.print(" • Remove filters")
|
|
console.print(" • specify extension search (show all)")
|
|
raise typer.Exit(0)
|
|
|
|
console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n")
|
|
|
|
for ext in results:
|
|
# Extension header
|
|
verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else ""
|
|
console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}")
|
|
console.print(f" {ext['description']}")
|
|
|
|
# Metadata
|
|
console.print(f"\n [dim]Author:[/dim] {ext.get('author', 'Unknown')}")
|
|
if ext.get('tags'):
|
|
tags_str = ", ".join(ext['tags'])
|
|
console.print(f" [dim]Tags:[/dim] {tags_str}")
|
|
|
|
# Source catalog
|
|
catalog_name = ext.get("_catalog_name", "")
|
|
install_allowed = ext.get("_install_allowed", True)
|
|
if catalog_name:
|
|
if install_allowed:
|
|
console.print(f" [dim]Catalog:[/dim] {catalog_name}")
|
|
else:
|
|
console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]")
|
|
|
|
# Stats
|
|
stats = []
|
|
if ext.get('downloads') is not None:
|
|
stats.append(f"Downloads: {ext['downloads']:,}")
|
|
if ext.get('stars') is not None:
|
|
stats.append(f"Stars: {ext['stars']}")
|
|
if stats:
|
|
console.print(f" [dim]{' | '.join(stats)}[/dim]")
|
|
|
|
# Links
|
|
if ext.get('repository'):
|
|
console.print(f" [dim]Repository:[/dim] {ext['repository']}")
|
|
|
|
# Install command (show warning if not installable)
|
|
if install_allowed:
|
|
console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}")
|
|
else:
|
|
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
|
|
console.print(
|
|
f" Add to an approved catalog with install_allowed: true, "
|
|
f"or install from a ZIP URL: specify extension add {ext['id']} --from <zip-url>"
|
|
)
|
|
console.print()
|
|
|
|
except ExtensionError as e:
|
|
console.print(f"\n[red]Error:[/red] {e}")
|
|
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@extension_app.command("info")
|
|
def extension_info(
|
|
extension: str = typer.Argument(help="Extension ID or name"),
|
|
):
|
|
"""Show detailed information about an extension."""
|
|
from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = ExtensionCatalog(project_root)
|
|
manager = ExtensionManager(project_root)
|
|
installed = manager.list_installed()
|
|
|
|
# Try to resolve from installed extensions first (by ID or name)
|
|
# Use allow_not_found=True since the extension may be catalog-only
|
|
resolved_installed_id, resolved_installed_name = _resolve_installed_extension(
|
|
extension, installed, "info", allow_not_found=True
|
|
)
|
|
|
|
# Try catalog lookup (with error handling)
|
|
# If we resolved an installed extension by display name, use its ID for catalog lookup
|
|
# to ensure we get the correct catalog entry (not a different extension with same name)
|
|
lookup_key = resolved_installed_id if resolved_installed_id else extension
|
|
ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info")
|
|
|
|
# Case 1: Found in catalog - show full catalog info
|
|
if ext_info:
|
|
_print_extension_info(ext_info, manager)
|
|
return
|
|
|
|
# Case 2: Installed locally but catalog lookup failed or not in catalog
|
|
if resolved_installed_id:
|
|
# Get local manifest info
|
|
ext_manifest = manager.get_extension(resolved_installed_id)
|
|
metadata = manager.registry.get(resolved_installed_id)
|
|
metadata_is_dict = isinstance(metadata, dict)
|
|
if not metadata_is_dict:
|
|
console.print(
|
|
"[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; "
|
|
"some information may be unavailable."
|
|
)
|
|
version = metadata.get("version", "unknown") if metadata_is_dict else "unknown"
|
|
|
|
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})")
|
|
console.print(f"ID: {resolved_installed_id}")
|
|
console.print()
|
|
|
|
if ext_manifest:
|
|
console.print(f"{ext_manifest.description}")
|
|
console.print()
|
|
# Author is optional in extension.yml, safely retrieve it
|
|
author = ext_manifest.data.get("extension", {}).get("author")
|
|
if author:
|
|
console.print(f"[dim]Author:[/dim] {author}")
|
|
console.print()
|
|
|
|
if ext_manifest.commands:
|
|
console.print("[bold]Commands:[/bold]")
|
|
for cmd in ext_manifest.commands:
|
|
console.print(f" • {cmd['name']}: {cmd.get('description', '')}")
|
|
console.print()
|
|
|
|
# Show catalog status
|
|
if catalog_error:
|
|
console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}")
|
|
console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]")
|
|
else:
|
|
console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)")
|
|
|
|
console.print()
|
|
console.print("[green]✓ Installed[/green]")
|
|
priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None)
|
|
console.print(f"[dim]Priority:[/dim] {priority}")
|
|
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
|
|
return
|
|
|
|
# Case 3: Not found anywhere
|
|
if catalog_error:
|
|
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
|
|
console.print("\nTry again when online, or use the extension ID directly.")
|
|
else:
|
|
console.print(f"[red]Error:[/red] Extension '{extension}' not found")
|
|
console.print("\nTry: specify extension search")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
def _print_extension_info(ext_info: dict, manager):
|
|
"""Print formatted extension info from catalog data."""
|
|
from .extensions import normalize_priority
|
|
|
|
# Header
|
|
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
|
|
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
|
|
console.print(f"ID: {ext_info['id']}")
|
|
console.print()
|
|
|
|
# Description
|
|
console.print(f"{ext_info['description']}")
|
|
console.print()
|
|
|
|
# Author and License
|
|
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
|
|
console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
|
|
|
|
# Source catalog
|
|
if ext_info.get("_catalog_name"):
|
|
install_allowed = ext_info.get("_install_allowed", True)
|
|
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
|
|
console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}")
|
|
console.print()
|
|
|
|
# Requirements
|
|
if ext_info.get('requires'):
|
|
console.print("[bold]Requirements:[/bold]")
|
|
reqs = ext_info['requires']
|
|
if reqs.get('speckit_version'):
|
|
console.print(f" • Spec Kit: {reqs['speckit_version']}")
|
|
if reqs.get('tools'):
|
|
for tool in reqs['tools']:
|
|
tool_name = tool['name']
|
|
tool_version = tool.get('version', 'any')
|
|
required = " (required)" if tool.get('required') else " (optional)"
|
|
console.print(f" • {tool_name}: {tool_version}{required}")
|
|
console.print()
|
|
|
|
# Provides
|
|
if ext_info.get('provides'):
|
|
console.print("[bold]Provides:[/bold]")
|
|
provides = ext_info['provides']
|
|
if provides.get('commands'):
|
|
console.print(f" • Commands: {provides['commands']}")
|
|
if provides.get('hooks'):
|
|
console.print(f" • Hooks: {provides['hooks']}")
|
|
console.print()
|
|
|
|
# Tags
|
|
if ext_info.get('tags'):
|
|
tags_str = ", ".join(ext_info['tags'])
|
|
console.print(f"[bold]Tags:[/bold] {tags_str}")
|
|
console.print()
|
|
|
|
# Statistics
|
|
stats = []
|
|
if ext_info.get('downloads') is not None:
|
|
stats.append(f"Downloads: {ext_info['downloads']:,}")
|
|
if ext_info.get('stars') is not None:
|
|
stats.append(f"Stars: {ext_info['stars']}")
|
|
if stats:
|
|
console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}")
|
|
console.print()
|
|
|
|
# Links
|
|
console.print("[bold]Links:[/bold]")
|
|
if ext_info.get('repository'):
|
|
console.print(f" • Repository: {ext_info['repository']}")
|
|
if ext_info.get('homepage'):
|
|
console.print(f" • Homepage: {ext_info['homepage']}")
|
|
if ext_info.get('documentation'):
|
|
console.print(f" • Documentation: {ext_info['documentation']}")
|
|
if ext_info.get('changelog'):
|
|
console.print(f" • Changelog: {ext_info['changelog']}")
|
|
console.print()
|
|
|
|
# Installation status and command
|
|
is_installed = manager.registry.is_installed(ext_info['id'])
|
|
install_allowed = ext_info.get("_install_allowed", True)
|
|
if is_installed:
|
|
console.print("[green]✓ Installed[/green]")
|
|
metadata = manager.registry.get(ext_info['id'])
|
|
priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
|
|
console.print(f"[dim]Priority:[/dim] {priority}")
|
|
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
|
|
elif install_allowed:
|
|
console.print("[yellow]Not installed[/yellow]")
|
|
console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}")
|
|
else:
|
|
catalog_name = ext_info.get("_catalog_name", "community")
|
|
console.print("[yellow]Not installed[/yellow]")
|
|
console.print(
|
|
f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog "
|
|
f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
|
|
f"with install_allowed: true to enable installation."
|
|
)
|
|
|
|
|
|
@extension_app.command("update")
|
|
def extension_update(
|
|
extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"),
|
|
):
|
|
"""Update extension(s) to latest version."""
|
|
from .extensions import (
|
|
ExtensionManager,
|
|
ExtensionCatalog,
|
|
ExtensionError,
|
|
ValidationError,
|
|
CommandRegistrar,
|
|
HookExecutor,
|
|
normalize_priority,
|
|
)
|
|
from packaging import version as pkg_version
|
|
import shutil
|
|
|
|
project_root = _require_specify_project()
|
|
manager = ExtensionManager(project_root)
|
|
catalog = ExtensionCatalog(project_root)
|
|
speckit_version = get_speckit_version()
|
|
|
|
try:
|
|
# Get list of extensions to update
|
|
installed = manager.list_installed()
|
|
if extension:
|
|
# Update specific extension - resolve ID from argument (handles ambiguous names)
|
|
extension_id, _ = _resolve_installed_extension(extension, installed, "update")
|
|
extensions_to_update = [extension_id]
|
|
else:
|
|
# Update all extensions
|
|
extensions_to_update = [ext["id"] for ext in installed]
|
|
|
|
if not extensions_to_update:
|
|
console.print("[yellow]No extensions installed[/yellow]")
|
|
raise typer.Exit(0)
|
|
|
|
console.print("🔄 Checking for updates...\n")
|
|
|
|
updates_available = []
|
|
|
|
for ext_id in extensions_to_update:
|
|
# Get installed version
|
|
metadata = manager.registry.get(ext_id)
|
|
if metadata is None or not isinstance(metadata, dict) or "version" not in metadata:
|
|
console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)")
|
|
continue
|
|
try:
|
|
installed_version = pkg_version.Version(metadata["version"])
|
|
except pkg_version.InvalidVersion:
|
|
console.print(
|
|
f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)"
|
|
)
|
|
continue
|
|
|
|
# Get catalog info
|
|
ext_info = catalog.get_extension_info(ext_id)
|
|
if not ext_info:
|
|
console.print(f"⚠ {ext_id}: Not found in catalog (skipping)")
|
|
continue
|
|
|
|
# Check if installation is allowed from this catalog
|
|
if not ext_info.get("_install_allowed", True):
|
|
console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)")
|
|
continue
|
|
|
|
try:
|
|
catalog_version = pkg_version.Version(ext_info["version"])
|
|
except pkg_version.InvalidVersion:
|
|
console.print(
|
|
f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)"
|
|
)
|
|
continue
|
|
|
|
if catalog_version > installed_version:
|
|
updates_available.append(
|
|
{
|
|
"id": ext_id,
|
|
"name": ext_info.get("name", ext_id), # Display name for status messages
|
|
"installed": str(installed_version),
|
|
"available": str(catalog_version),
|
|
"download_url": ext_info.get("download_url"),
|
|
}
|
|
)
|
|
else:
|
|
console.print(f"✓ {ext_id}: Up to date (v{installed_version})")
|
|
|
|
if not updates_available:
|
|
console.print("\n[green]All extensions are up to date![/green]")
|
|
raise typer.Exit(0)
|
|
|
|
# Show available updates
|
|
console.print("\n[bold]Updates available:[/bold]\n")
|
|
for update in updates_available:
|
|
console.print(
|
|
f" • {update['id']}: {update['installed']} → {update['available']}"
|
|
)
|
|
|
|
console.print()
|
|
confirm = typer.confirm("Update these extensions?")
|
|
if not confirm:
|
|
console.print("Cancelled")
|
|
raise typer.Exit(0)
|
|
|
|
# Perform updates with atomic backup/restore
|
|
console.print()
|
|
updated_extensions = []
|
|
failed_updates = []
|
|
registrar = CommandRegistrar()
|
|
hook_executor = HookExecutor(project_root)
|
|
from .agents import CommandRegistrar as _AgentReg # used in backup and rollback paths
|
|
|
|
# UNSET sentinel: backup not yet captured (exception before backup step)
|
|
UNSET = object()
|
|
|
|
for update in updates_available:
|
|
extension_id = update["id"]
|
|
ext_name = update["name"] # Use display name for user-facing messages
|
|
console.print(f"📦 Updating {ext_name}...")
|
|
|
|
# Backup paths
|
|
backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update"
|
|
backup_ext_dir = backup_base / "extension"
|
|
backup_commands_dir = backup_base / "commands"
|
|
backup_config_dir = backup_base / "config"
|
|
|
|
# Store backup state
|
|
backup_registry_entry = None # None means registry entry not yet captured
|
|
backup_installed = UNSET # Original installed list from extensions.yml
|
|
backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured
|
|
backed_up_command_files = {}
|
|
|
|
try:
|
|
# 1. Backup registry entry (always, even if extension dir doesn't exist)
|
|
backup_registry_entry = manager.registry.get(extension_id)
|
|
|
|
# 2. Backup extension directory
|
|
extension_dir = manager.extensions_dir / extension_id
|
|
if extension_dir.exists():
|
|
backup_base.mkdir(parents=True, exist_ok=True)
|
|
if backup_ext_dir.exists():
|
|
shutil.rmtree(backup_ext_dir)
|
|
shutil.copytree(extension_dir, backup_ext_dir)
|
|
|
|
# Backup config files separately so they can be restored
|
|
# after a successful install (install_from_directory clears dest dir).
|
|
config_files = list(extension_dir.glob("*-config.yml")) + list(
|
|
extension_dir.glob("*-config.local.yml")
|
|
)
|
|
for cfg_file in config_files:
|
|
backup_config_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(cfg_file, backup_config_dir / cfg_file.name)
|
|
|
|
# 3. Backup command files for all agents
|
|
registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {}
|
|
for agent_name, cmd_names in registered_commands.items():
|
|
if agent_name not in registrar.AGENT_CONFIGS:
|
|
continue
|
|
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
|
commands_dir = project_root / agent_config["dir"]
|
|
|
|
for cmd_name in cmd_names:
|
|
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
|
|
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
|
if cmd_file.exists():
|
|
backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name
|
|
backup_cmd_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(cmd_file, backup_cmd_path)
|
|
backed_up_command_files[str(cmd_file)] = str(backup_cmd_path)
|
|
|
|
# Also backup copilot prompt files
|
|
if agent_name == "copilot":
|
|
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
|
if prompt_file.exists():
|
|
backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name
|
|
backup_prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(prompt_file, backup_prompt_path)
|
|
backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)
|
|
|
|
# 4. Backup hooks and installed list from extensions.yml
|
|
# get_project_config() always normalizes installed->[] and hooks->{},
|
|
# so no sentinel is needed to distinguish key-absent from key-empty.
|
|
config = hook_executor.get_project_config()
|
|
if isinstance(config, dict):
|
|
import copy
|
|
# Deep-copy so nested mapping entries (e.g. version-pin dicts)
|
|
# are not affected by in-place mutations during the update.
|
|
backup_installed = copy.deepcopy(config.get("installed", []))
|
|
backup_hooks = {}
|
|
for hook_name, hook_list in config.get("hooks", {}).items():
|
|
if not isinstance(hook_list, list):
|
|
continue
|
|
ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id]
|
|
if ext_hooks:
|
|
backup_hooks[hook_name] = ext_hooks
|
|
|
|
# 5. Download new version
|
|
zip_path = catalog.download_extension(extension_id)
|
|
try:
|
|
# 6. Validate extension ID from ZIP BEFORE modifying installation
|
|
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
|
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
import yaml
|
|
manifest_data = None
|
|
namelist = zf.namelist()
|
|
|
|
# First try root-level extension.yml
|
|
if "extension.yml" in namelist:
|
|
with zf.open("extension.yml") as f:
|
|
manifest_data = yaml.safe_load(f) or {}
|
|
else:
|
|
# Look for extension.yml in a single top-level subdirectory
|
|
# (e.g., "repo-name-branch/extension.yml")
|
|
manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
|
|
if len(manifest_paths) == 1:
|
|
with zf.open(manifest_paths[0]) as f:
|
|
manifest_data = yaml.safe_load(f) or {}
|
|
|
|
if manifest_data is None:
|
|
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
|
|
|
|
zip_extension_id = manifest_data.get("extension", {}).get("id")
|
|
if zip_extension_id != extension_id:
|
|
raise ValueError(
|
|
f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'"
|
|
)
|
|
|
|
# 7. Remove old extension (handles command file cleanup and registry removal)
|
|
manager.remove(extension_id, keep_config=True)
|
|
|
|
# 8. Install new version
|
|
_ = manager.install_from_zip(zip_path, speckit_version)
|
|
|
|
# Restore user config files from backup after successful install.
|
|
new_extension_dir = manager.extensions_dir / extension_id
|
|
if backup_config_dir.exists() and new_extension_dir.exists():
|
|
for cfg_file in backup_config_dir.iterdir():
|
|
if cfg_file.is_file():
|
|
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
|
|
|
|
# 9. Restore metadata from backup (installed_at, enabled state)
|
|
if backup_registry_entry and isinstance(backup_registry_entry, dict):
|
|
# Copy current registry entry to avoid mutating internal
|
|
# registry state before explicit restore().
|
|
current_metadata = manager.registry.get(extension_id)
|
|
if current_metadata is None or not isinstance(current_metadata, dict):
|
|
raise RuntimeError(
|
|
f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete"
|
|
)
|
|
new_metadata = dict(current_metadata)
|
|
|
|
# Preserve the original installation timestamp
|
|
if "installed_at" in backup_registry_entry:
|
|
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
|
|
|
|
# Preserve the original priority (normalized to handle corruption)
|
|
if "priority" in backup_registry_entry:
|
|
new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"])
|
|
|
|
# If extension was disabled before update, disable it again
|
|
if not backup_registry_entry.get("enabled", True):
|
|
new_metadata["enabled"] = False
|
|
|
|
# Use restore() instead of update() because update() always
|
|
# preserves the existing installed_at, ignoring our override
|
|
manager.registry.restore(extension_id, new_metadata)
|
|
|
|
# Also disable hooks in extensions.yml if extension was disabled
|
|
if not backup_registry_entry.get("enabled", True):
|
|
config = hook_executor.get_project_config()
|
|
if "hooks" in config:
|
|
for hook_name in config["hooks"]:
|
|
for hook in config["hooks"][hook_name]:
|
|
if hook.get("extension") == extension_id:
|
|
hook["enabled"] = False
|
|
hook_executor.save_project_config(config)
|
|
finally:
|
|
# Clean up downloaded ZIP
|
|
if zip_path.exists():
|
|
zip_path.unlink()
|
|
|
|
# 10. Clean up backup on success
|
|
if backup_base.exists():
|
|
shutil.rmtree(backup_base)
|
|
|
|
console.print(f" [green]✓[/green] Updated to v{update['available']}")
|
|
updated_extensions.append(ext_name)
|
|
|
|
except KeyboardInterrupt:
|
|
raise
|
|
except Exception as e:
|
|
console.print(f" [red]✗[/red] Failed: {e}")
|
|
failed_updates.append((ext_name, str(e)))
|
|
|
|
# Rollback on failure
|
|
console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...")
|
|
|
|
try:
|
|
# Restore extension directory
|
|
# Only perform destructive rollback if backup exists (meaning we
|
|
# actually modified the extension). This avoids deleting a valid
|
|
# installation when failure happened before changes were made.
|
|
extension_dir = manager.extensions_dir / extension_id
|
|
if backup_ext_dir.exists():
|
|
if extension_dir.exists():
|
|
shutil.rmtree(extension_dir)
|
|
shutil.copytree(backup_ext_dir, extension_dir)
|
|
|
|
# Remove any NEW command files created by failed install
|
|
# (files that weren't in the original backup)
|
|
try:
|
|
new_registry_entry = manager.registry.get(extension_id)
|
|
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
|
|
new_registered_commands = {}
|
|
else:
|
|
new_registered_commands = new_registry_entry.get("registered_commands", {})
|
|
for agent_name, cmd_names in new_registered_commands.items():
|
|
if agent_name not in registrar.AGENT_CONFIGS:
|
|
continue
|
|
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
|
commands_dir = project_root / agent_config["dir"]
|
|
|
|
for cmd_name in cmd_names:
|
|
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
|
|
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
|
# Delete if it exists and wasn't in our backup
|
|
if cmd_file.exists() and str(cmd_file) not in backed_up_command_files:
|
|
cmd_file.unlink()
|
|
|
|
# Also handle copilot prompt files
|
|
if agent_name == "copilot":
|
|
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
|
if prompt_file.exists() and str(prompt_file) not in backed_up_command_files:
|
|
prompt_file.unlink()
|
|
except KeyError:
|
|
pass # No new registry entry exists, nothing to clean up
|
|
|
|
# Restore backed up command files
|
|
for original_path, backup_path in backed_up_command_files.items():
|
|
backup_file = Path(backup_path)
|
|
if backup_file.exists():
|
|
original_file = Path(original_path)
|
|
original_file.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(backup_file, original_file)
|
|
|
|
# Restore metadata in extensions.yml (hooks and installed list).
|
|
# Only run if backup step 4 was reached (backup_hooks is not None);
|
|
# otherwise we have no safe baseline to restore from and could corrupt
|
|
# the config by removing pre-existing hooks.
|
|
if backup_hooks is not None:
|
|
config = hook_executor.get_project_config()
|
|
if not isinstance(config, dict):
|
|
config = {}
|
|
|
|
modified = False
|
|
|
|
# 1. Restore hooks in extensions.yml
|
|
if not isinstance(config.get("hooks"), dict):
|
|
config["hooks"] = {}
|
|
modified = True
|
|
|
|
# Remove any hooks for this extension added by the failed install
|
|
for hook_name in list(config["hooks"].keys()):
|
|
hooks_list = config["hooks"][hook_name]
|
|
if not isinstance(hooks_list, list):
|
|
config["hooks"][hook_name] = []
|
|
modified = True
|
|
continue
|
|
|
|
original_len = len(hooks_list)
|
|
config["hooks"][hook_name] = [
|
|
h for h in hooks_list
|
|
if isinstance(h, dict) and h.get("extension") != extension_id
|
|
]
|
|
if len(config["hooks"][hook_name]) != original_len:
|
|
modified = True
|
|
|
|
# Add back the backed-up hooks
|
|
if backup_hooks:
|
|
for hook_name, hooks in backup_hooks.items():
|
|
if not isinstance(config["hooks"].get(hook_name), list):
|
|
config["hooks"][hook_name] = []
|
|
config["hooks"][hook_name].extend(hooks)
|
|
modified = True
|
|
|
|
# 2. Restore installed list in extensions.yml
|
|
if backup_installed is not UNSET:
|
|
if config.get("installed") != backup_installed:
|
|
config["installed"] = backup_installed
|
|
modified = True
|
|
|
|
if modified:
|
|
hook_executor.save_project_config(config)
|
|
|
|
# Restore registry entry (use restore() since entry was removed)
|
|
if backup_registry_entry:
|
|
manager.registry.restore(extension_id, backup_registry_entry)
|
|
|
|
console.print(" [green]✓[/green] Rollback successful")
|
|
# Clean up backup directory only on successful rollback
|
|
if backup_base.exists():
|
|
shutil.rmtree(backup_base)
|
|
except Exception as rollback_error:
|
|
console.print(f" [red]✗[/red] Rollback failed: {rollback_error}")
|
|
console.print(f" [dim]Backup preserved at: {backup_base}[/dim]")
|
|
|
|
# Summary
|
|
console.print()
|
|
if updated_extensions:
|
|
console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)")
|
|
if failed_updates:
|
|
console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):")
|
|
for ext_name, error in failed_updates:
|
|
console.print(f" • {ext_name}: {error}")
|
|
raise typer.Exit(1)
|
|
|
|
except ValidationError as e:
|
|
console.print(f"\n[red]Validation Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
except ExtensionError as e:
|
|
console.print(f"\n[red]Error:[/red] {e}")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@extension_app.command("enable")
|
|
def extension_enable(
|
|
extension: str = typer.Argument(help="Extension ID or name to enable"),
|
|
):
|
|
"""Enable a disabled extension."""
|
|
from .extensions import ExtensionManager, HookExecutor
|
|
|
|
project_root = _require_specify_project()
|
|
manager = ExtensionManager(project_root)
|
|
hook_executor = HookExecutor(project_root)
|
|
|
|
# Resolve extension ID from argument (handles ambiguous names)
|
|
installed = manager.list_installed()
|
|
extension_id, display_name = _resolve_installed_extension(extension, installed, "enable")
|
|
|
|
# Update registry
|
|
metadata = manager.registry.get(extension_id)
|
|
if metadata is None or not isinstance(metadata, dict):
|
|
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
|
raise typer.Exit(1)
|
|
|
|
if metadata.get("enabled", True):
|
|
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
|
|
raise typer.Exit(0)
|
|
|
|
manager.registry.update(extension_id, {"enabled": True})
|
|
|
|
# Enable hooks in extensions.yml
|
|
config = hook_executor.get_project_config()
|
|
if "hooks" in config:
|
|
for hook_name in config["hooks"]:
|
|
for hook in config["hooks"][hook_name]:
|
|
if hook.get("extension") == extension_id:
|
|
hook["enabled"] = True
|
|
hook_executor.save_project_config(config)
|
|
|
|
console.print(f"[green]✓[/green] Extension '{display_name}' enabled")
|
|
|
|
|
|
@extension_app.command("disable")
|
|
def extension_disable(
|
|
extension: str = typer.Argument(help="Extension ID or name to disable"),
|
|
):
|
|
"""Disable an extension without removing it."""
|
|
from .extensions import ExtensionManager, HookExecutor
|
|
|
|
project_root = _require_specify_project()
|
|
manager = ExtensionManager(project_root)
|
|
hook_executor = HookExecutor(project_root)
|
|
|
|
# Resolve extension ID from argument (handles ambiguous names)
|
|
installed = manager.list_installed()
|
|
extension_id, display_name = _resolve_installed_extension(extension, installed, "disable")
|
|
|
|
# Update registry
|
|
metadata = manager.registry.get(extension_id)
|
|
if metadata is None or not isinstance(metadata, dict):
|
|
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
|
raise typer.Exit(1)
|
|
|
|
if not metadata.get("enabled", True):
|
|
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
|
|
raise typer.Exit(0)
|
|
|
|
manager.registry.update(extension_id, {"enabled": False})
|
|
|
|
# Disable hooks in extensions.yml
|
|
config = hook_executor.get_project_config()
|
|
if "hooks" in config:
|
|
for hook_name in config["hooks"]:
|
|
for hook in config["hooks"][hook_name]:
|
|
if hook.get("extension") == extension_id:
|
|
hook["enabled"] = False
|
|
hook_executor.save_project_config(config)
|
|
|
|
console.print(f"[green]✓[/green] Extension '{display_name}' disabled")
|
|
console.print("\nCommands will no longer be available. Hooks will not execute.")
|
|
console.print(f"To re-enable: specify extension enable {extension_id}")
|
|
|
|
|
|
@extension_app.command("set-priority")
|
|
def extension_set_priority(
|
|
extension: str = typer.Argument(help="Extension ID or name"),
|
|
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
|
|
):
|
|
"""Set the resolution priority of an installed extension."""
|
|
from .extensions import ExtensionManager
|
|
|
|
project_root = _require_specify_project()
|
|
# Validate priority
|
|
if priority < 1:
|
|
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
raise typer.Exit(1)
|
|
|
|
manager = ExtensionManager(project_root)
|
|
|
|
# Resolve extension ID from argument (handles ambiguous names)
|
|
installed = manager.list_installed()
|
|
extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")
|
|
|
|
# Get current metadata
|
|
metadata = manager.registry.get(extension_id)
|
|
if metadata is None or not isinstance(metadata, dict):
|
|
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
|
raise typer.Exit(1)
|
|
|
|
from .extensions import normalize_priority
|
|
raw_priority = metadata.get("priority")
|
|
# Only skip if the stored value is already a valid int equal to requested priority
|
|
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
|
if isinstance(raw_priority, int) and raw_priority == priority:
|
|
console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]")
|
|
raise typer.Exit(0)
|
|
|
|
old_priority = normalize_priority(raw_priority)
|
|
|
|
# Update priority
|
|
manager.registry.update(extension_id, {"priority": priority})
|
|
|
|
console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}")
|
|
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
|
|
|
|
|
# ===== Workflow Commands =====
|
|
|
|
workflow_app = typer.Typer(
|
|
name="workflow",
|
|
help="Manage and run automation workflows",
|
|
add_completion=False,
|
|
)
|
|
app.add_typer(workflow_app, name="workflow")
|
|
|
|
workflow_catalog_app = typer.Typer(
|
|
name="catalog",
|
|
help="Manage workflow catalogs",
|
|
add_completion=False,
|
|
)
|
|
workflow_app.add_typer(workflow_catalog_app, name="catalog")
|
|
|
|
|
|
@workflow_app.command("run")
|
|
def workflow_run(
|
|
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
|
|
input_values: list[str] | None = typer.Option(
|
|
None, "--input", "-i", help="Input values as key=value pairs"
|
|
),
|
|
):
|
|
"""Run a workflow from an installed ID or local YAML path."""
|
|
from .workflows.engine import WorkflowEngine
|
|
|
|
project_root = _require_specify_project()
|
|
engine = WorkflowEngine(project_root)
|
|
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
|
|
|
try:
|
|
definition = engine.load_workflow(source)
|
|
except FileNotFoundError:
|
|
console.print(f"[red]Error:[/red] Workflow not found: {source}")
|
|
raise typer.Exit(1)
|
|
except ValueError as exc:
|
|
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
# Validate
|
|
errors = engine.validate(definition)
|
|
if errors:
|
|
console.print("[red]Workflow validation failed:[/red]")
|
|
for err in errors:
|
|
console.print(f" • {err}")
|
|
raise typer.Exit(1)
|
|
|
|
# Parse inputs
|
|
inputs: dict[str, Any] = {}
|
|
if input_values:
|
|
for kv in input_values:
|
|
if "=" not in kv:
|
|
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
|
|
raise typer.Exit(1)
|
|
key, _, value = kv.partition("=")
|
|
inputs[key.strip()] = value.strip()
|
|
|
|
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
|
|
console.print(f"[dim]Version: {definition.version}[/dim]\n")
|
|
|
|
try:
|
|
state = engine.execute(definition, inputs)
|
|
except ValueError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
except Exception as exc:
|
|
console.print(f"[red]Workflow failed:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
status_colors = {
|
|
"completed": "green",
|
|
"paused": "yellow",
|
|
"failed": "red",
|
|
"aborted": "red",
|
|
}
|
|
color = status_colors.get(state.status.value, "white")
|
|
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
|
console.print(f"[dim]Run ID: {state.run_id}[/dim]")
|
|
|
|
if state.status.value == "paused":
|
|
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
|
|
|
|
|
|
@workflow_app.command("resume")
|
|
def workflow_resume(
|
|
run_id: str = typer.Argument(..., help="Run ID to resume"),
|
|
):
|
|
"""Resume a paused or failed workflow run."""
|
|
from .workflows.engine import WorkflowEngine
|
|
|
|
project_root = _require_specify_project()
|
|
engine = WorkflowEngine(project_root)
|
|
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
|
|
|
try:
|
|
state = engine.resume(run_id)
|
|
except FileNotFoundError:
|
|
console.print(f"[red]Error:[/red] Run not found: {run_id}")
|
|
raise typer.Exit(1)
|
|
except ValueError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
except Exception as exc:
|
|
console.print(f"[red]Resume failed:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
status_colors = {
|
|
"completed": "green",
|
|
"paused": "yellow",
|
|
"failed": "red",
|
|
"aborted": "red",
|
|
}
|
|
color = status_colors.get(state.status.value, "white")
|
|
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
|
|
|
|
|
@workflow_app.command("status")
|
|
def workflow_status(
|
|
run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
|
|
):
|
|
"""Show workflow run status."""
|
|
from .workflows.engine import WorkflowEngine
|
|
|
|
project_root = _require_specify_project()
|
|
engine = WorkflowEngine(project_root)
|
|
|
|
if run_id:
|
|
try:
|
|
from .workflows.engine import RunState
|
|
state = RunState.load(run_id, project_root)
|
|
except FileNotFoundError:
|
|
console.print(f"[red]Error:[/red] Run not found: {run_id}")
|
|
raise typer.Exit(1)
|
|
|
|
status_colors = {
|
|
"completed": "green",
|
|
"paused": "yellow",
|
|
"failed": "red",
|
|
"aborted": "red",
|
|
"running": "blue",
|
|
"created": "dim",
|
|
}
|
|
color = status_colors.get(state.status.value, "white")
|
|
|
|
console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]")
|
|
console.print(f" Workflow: {state.workflow_id}")
|
|
console.print(f" Status: [{color}]{state.status.value}[/{color}]")
|
|
console.print(f" Created: {state.created_at}")
|
|
console.print(f" Updated: {state.updated_at}")
|
|
|
|
if state.current_step_id:
|
|
console.print(f" Current: {state.current_step_id}")
|
|
|
|
if state.step_results:
|
|
console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]")
|
|
for step_id, step_data in state.step_results.items():
|
|
s = step_data.get("status", "unknown")
|
|
sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white")
|
|
console.print(f" [{sc}]●[/{sc}] {step_id}: {s}")
|
|
else:
|
|
runs = engine.list_runs()
|
|
if not runs:
|
|
console.print("[yellow]No workflow runs found.[/yellow]")
|
|
return
|
|
|
|
console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n")
|
|
for run_data in runs:
|
|
s = run_data.get("status", "unknown")
|
|
sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white")
|
|
console.print(
|
|
f" [{sc}]●[/{sc}] {run_data['run_id']} "
|
|
f"{run_data.get('workflow_id', '?')} "
|
|
f"[{sc}]{s}[/{sc}] "
|
|
f"[dim]{run_data.get('updated_at', '?')}[/dim]"
|
|
)
|
|
|
|
|
|
@workflow_app.command("list")
|
|
def workflow_list():
|
|
"""List installed workflows."""
|
|
from .workflows.catalog import WorkflowRegistry
|
|
|
|
project_root = _require_specify_project()
|
|
registry = WorkflowRegistry(project_root)
|
|
installed = registry.list()
|
|
|
|
if not installed:
|
|
console.print("[yellow]No workflows installed.[/yellow]")
|
|
console.print("\nInstall a workflow with:")
|
|
console.print(" [cyan]specify workflow add <workflow-id>[/cyan]")
|
|
return
|
|
|
|
console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n")
|
|
for wf_id, wf_data in installed.items():
|
|
console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}")
|
|
desc = wf_data.get("description", "")
|
|
if desc:
|
|
console.print(f" {desc}")
|
|
console.print()
|
|
|
|
|
|
@workflow_app.command("add")
|
|
def workflow_add(
|
|
source: str = typer.Argument(..., help="Workflow ID, URL, or local path"),
|
|
):
|
|
"""Install a workflow from catalog, URL, or local path."""
|
|
from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError
|
|
from .workflows.engine import WorkflowDefinition
|
|
|
|
project_root = _require_specify_project()
|
|
registry = WorkflowRegistry(project_root)
|
|
workflows_dir = project_root / ".specify" / "workflows"
|
|
|
|
def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
|
|
"""Validate and install a workflow from a local YAML file."""
|
|
try:
|
|
definition = WorkflowDefinition.from_yaml(yaml_path)
|
|
except (ValueError, yaml.YAMLError) as exc:
|
|
console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}")
|
|
raise typer.Exit(1)
|
|
if not definition.id or not definition.id.strip():
|
|
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
|
|
raise typer.Exit(1)
|
|
|
|
from .workflows.engine import validate_workflow
|
|
errors = validate_workflow(definition)
|
|
if errors:
|
|
console.print("[red]Error:[/red] Workflow validation failed:")
|
|
for err in errors:
|
|
console.print(f" \u2022 {err}")
|
|
raise typer.Exit(1)
|
|
|
|
dest_dir = workflows_dir / definition.id
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
import shutil
|
|
shutil.copy2(yaml_path, dest_dir / "workflow.yml")
|
|
registry.add(definition.id, {
|
|
"name": definition.name,
|
|
"version": definition.version,
|
|
"description": definition.description,
|
|
"source": source_label,
|
|
})
|
|
console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed")
|
|
|
|
# Try as URL (http/https)
|
|
if source.startswith("http://") or source.startswith("https://"):
|
|
from ipaddress import ip_address
|
|
from urllib.parse import urlparse
|
|
from specify_cli.authentication.http import open_url as _open_url
|
|
|
|
parsed_src = urlparse(source)
|
|
src_host = parsed_src.hostname or ""
|
|
src_loopback = src_host == "localhost"
|
|
if not src_loopback:
|
|
try:
|
|
src_loopback = ip_address(src_host).is_loopback
|
|
except ValueError:
|
|
# Host is not an IP literal (e.g., a DNS name); keep default non-loopback.
|
|
pass
|
|
if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback):
|
|
console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.")
|
|
raise typer.Exit(1)
|
|
|
|
import tempfile
|
|
try:
|
|
with _open_url(source, timeout=30) as resp:
|
|
final_url = resp.geturl()
|
|
final_parsed = urlparse(final_url)
|
|
final_host = final_parsed.hostname or ""
|
|
final_lb = final_host == "localhost"
|
|
if not final_lb:
|
|
try:
|
|
final_lb = ip_address(final_host).is_loopback
|
|
except ValueError:
|
|
# Redirect host is not an IP literal; keep loopback as determined above.
|
|
pass
|
|
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
|
|
console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}")
|
|
raise typer.Exit(1)
|
|
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
|
|
tmp.write(resp.read())
|
|
tmp_path = Path(tmp.name)
|
|
except typer.Exit:
|
|
raise
|
|
except Exception as exc:
|
|
console.print(f"[red]Error:[/red] Failed to download workflow: {exc}")
|
|
raise typer.Exit(1)
|
|
try:
|
|
_validate_and_install_local(tmp_path, source)
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
return
|
|
|
|
# Try as a local file/directory
|
|
source_path = Path(source)
|
|
if source_path.exists():
|
|
if source_path.is_file() and source_path.suffix in (".yml", ".yaml"):
|
|
_validate_and_install_local(source_path, str(source_path))
|
|
return
|
|
elif source_path.is_dir():
|
|
wf_file = source_path / "workflow.yml"
|
|
if not wf_file.exists():
|
|
console.print(f"[red]Error:[/red] No workflow.yml found in {source}")
|
|
raise typer.Exit(1)
|
|
_validate_and_install_local(wf_file, str(source_path))
|
|
return
|
|
|
|
# Try from catalog
|
|
catalog = WorkflowCatalog(project_root)
|
|
try:
|
|
info = catalog.get_workflow_info(source)
|
|
except WorkflowCatalogError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
if not info:
|
|
console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog")
|
|
raise typer.Exit(1)
|
|
|
|
if not info.get("_install_allowed", True):
|
|
console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog")
|
|
console.print("Direct installation is not enabled for this catalog source.")
|
|
raise typer.Exit(1)
|
|
|
|
workflow_url = info.get("url")
|
|
if not workflow_url:
|
|
console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog")
|
|
raise typer.Exit(1)
|
|
|
|
# Validate URL scheme (HTTPS required, HTTP allowed for localhost only)
|
|
from ipaddress import ip_address
|
|
from urllib.parse import urlparse
|
|
|
|
parsed_url = urlparse(workflow_url)
|
|
url_host = parsed_url.hostname or ""
|
|
is_loopback = False
|
|
if url_host == "localhost":
|
|
is_loopback = True
|
|
else:
|
|
try:
|
|
is_loopback = ip_address(url_host).is_loopback
|
|
except ValueError:
|
|
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
|
|
pass
|
|
if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback):
|
|
console.print(
|
|
f"[red]Error:[/red] Workflow '{source}' has an invalid install URL. "
|
|
"Only HTTPS URLs are allowed, except HTTP for localhost/loopback."
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
workflow_dir = workflows_dir / source
|
|
# Validate that source is a safe directory name (no path traversal)
|
|
try:
|
|
workflow_dir.resolve().relative_to(workflows_dir.resolve())
|
|
except ValueError:
|
|
console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}")
|
|
raise typer.Exit(1)
|
|
workflow_file = workflow_dir / "workflow.yml"
|
|
|
|
try:
|
|
from specify_cli.authentication.http import open_url as _open_url
|
|
|
|
workflow_dir.mkdir(parents=True, exist_ok=True)
|
|
with _open_url(workflow_url, timeout=30) as response:
|
|
# Validate final URL after redirects
|
|
final_url = response.geturl()
|
|
final_parsed = urlparse(final_url)
|
|
final_host = final_parsed.hostname or ""
|
|
final_loopback = final_host == "localhost"
|
|
if not final_loopback:
|
|
try:
|
|
final_loopback = ip_address(final_host).is_loopback
|
|
except ValueError:
|
|
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
|
|
pass
|
|
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback):
|
|
if workflow_dir.exists():
|
|
import shutil
|
|
shutil.rmtree(workflow_dir, ignore_errors=True)
|
|
console.print(
|
|
f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}"
|
|
)
|
|
raise typer.Exit(1)
|
|
workflow_file.write_bytes(response.read())
|
|
except Exception as exc:
|
|
if workflow_dir.exists():
|
|
import shutil
|
|
shutil.rmtree(workflow_dir, ignore_errors=True)
|
|
console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
# Validate the downloaded workflow before registering
|
|
try:
|
|
definition = WorkflowDefinition.from_yaml(workflow_file)
|
|
except (ValueError, yaml.YAMLError) as exc:
|
|
import shutil
|
|
shutil.rmtree(workflow_dir, ignore_errors=True)
|
|
console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
from .workflows.engine import validate_workflow
|
|
errors = validate_workflow(definition)
|
|
if errors:
|
|
import shutil
|
|
shutil.rmtree(workflow_dir, ignore_errors=True)
|
|
console.print("[red]Error:[/red] Downloaded workflow validation failed:")
|
|
for err in errors:
|
|
console.print(f" \u2022 {err}")
|
|
raise typer.Exit(1)
|
|
|
|
# Enforce that the workflow's internal ID matches the catalog key
|
|
if definition.id and definition.id != source:
|
|
import shutil
|
|
shutil.rmtree(workflow_dir, ignore_errors=True)
|
|
console.print(
|
|
f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) "
|
|
f"does not match catalog key ({source!r}). "
|
|
f"The catalog entry may be misconfigured."
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
registry.add(source, {
|
|
"name": definition.name or info.get("name", source),
|
|
"version": definition.version or info.get("version", "0.0.0"),
|
|
"description": definition.description or info.get("description", ""),
|
|
"source": "catalog",
|
|
"catalog_name": info.get("_catalog_name", ""),
|
|
"url": workflow_url,
|
|
})
|
|
console.print(f"[green]✓[/green] Workflow '{info.get('name', source)}' installed from catalog")
|
|
|
|
|
|
@workflow_app.command("remove")
|
|
def workflow_remove(
|
|
workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"),
|
|
):
|
|
"""Uninstall a workflow."""
|
|
from .workflows.catalog import WorkflowRegistry
|
|
|
|
project_root = _require_specify_project()
|
|
registry = WorkflowRegistry(project_root)
|
|
|
|
if not registry.is_installed(workflow_id):
|
|
console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed")
|
|
raise typer.Exit(1)
|
|
|
|
# Remove workflow files
|
|
workflow_dir = project_root / ".specify" / "workflows" / workflow_id
|
|
if workflow_dir.exists():
|
|
import shutil
|
|
shutil.rmtree(workflow_dir)
|
|
|
|
registry.remove(workflow_id)
|
|
console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed")
|
|
|
|
|
|
@workflow_app.command("search")
|
|
def workflow_search(
|
|
query: str | None = typer.Argument(None, help="Search query"),
|
|
tag: str | None = typer.Option(None, "--tag", help="Filter by tag"),
|
|
):
|
|
"""Search workflow catalogs."""
|
|
from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = WorkflowCatalog(project_root)
|
|
|
|
try:
|
|
results = catalog.search(query=query, tag=tag)
|
|
except WorkflowCatalogError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
if not results:
|
|
console.print("[yellow]No workflows found.[/yellow]")
|
|
return
|
|
|
|
console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n")
|
|
for wf in results:
|
|
console.print(f" [bold]{wf.get('name', wf.get('id', '?'))}[/bold] ({wf.get('id', '?')}) v{wf.get('version', '?')}")
|
|
desc = wf.get("description", "")
|
|
if desc:
|
|
console.print(f" {desc}")
|
|
tags = wf.get("tags", [])
|
|
if tags:
|
|
console.print(f" [dim]Tags: {', '.join(tags)}[/dim]")
|
|
console.print()
|
|
|
|
|
|
@workflow_app.command("info")
|
|
def workflow_info(
|
|
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
):
|
|
"""Show workflow details and step graph."""
|
|
from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError
|
|
from .workflows.engine import WorkflowEngine
|
|
|
|
project_root = _require_specify_project()
|
|
|
|
# Check installed first
|
|
registry = WorkflowRegistry(project_root)
|
|
installed = registry.get(workflow_id)
|
|
|
|
engine = WorkflowEngine(project_root)
|
|
|
|
definition = None
|
|
try:
|
|
definition = engine.load_workflow(workflow_id)
|
|
except FileNotFoundError:
|
|
# Local workflow definition not found on disk; fall back to
|
|
# catalog/registry lookup below.
|
|
pass
|
|
|
|
if definition:
|
|
console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})")
|
|
console.print(f" Version: {definition.version}")
|
|
if definition.author:
|
|
console.print(f" Author: {definition.author}")
|
|
if definition.description:
|
|
console.print(f" Description: {definition.description}")
|
|
if definition.default_integration:
|
|
console.print(f" Integration: {definition.default_integration}")
|
|
if installed:
|
|
console.print(" [green]Installed[/green]")
|
|
|
|
if definition.inputs:
|
|
console.print("\n [bold]Inputs:[/bold]")
|
|
for name, inp in definition.inputs.items():
|
|
if isinstance(inp, dict):
|
|
req = "required" if inp.get("required") else "optional"
|
|
console.print(f" {name} ({inp.get('type', 'string')}) — {req}")
|
|
|
|
if definition.steps:
|
|
console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]")
|
|
for step in definition.steps:
|
|
stype = step.get("type", "command")
|
|
console.print(f" → {step.get('id', '?')} [{stype}]")
|
|
return
|
|
|
|
# Try catalog
|
|
catalog = WorkflowCatalog(project_root)
|
|
try:
|
|
info = catalog.get_workflow_info(workflow_id)
|
|
except WorkflowCatalogError:
|
|
info = None
|
|
|
|
if info:
|
|
console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})")
|
|
console.print(f" Version: {info.get('version', '?')}")
|
|
if info.get("description"):
|
|
console.print(f" Description: {info['description']}")
|
|
if info.get("tags"):
|
|
console.print(f" Tags: {', '.join(info['tags'])}")
|
|
console.print(" [yellow]Not installed[/yellow]")
|
|
else:
|
|
console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found")
|
|
raise typer.Exit(1)
|
|
|
|
|
|
@workflow_catalog_app.command("list")
|
|
def workflow_catalog_list():
|
|
"""List configured workflow catalog sources."""
|
|
from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = WorkflowCatalog(project_root)
|
|
|
|
try:
|
|
configs = catalog.get_catalog_configs()
|
|
except WorkflowCatalogError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n")
|
|
for i, cfg in enumerate(configs):
|
|
install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]"
|
|
console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}")
|
|
console.print(f" {cfg['url']}")
|
|
if cfg.get("description"):
|
|
console.print(f" [dim]{cfg['description']}[/dim]")
|
|
console.print()
|
|
|
|
|
|
@workflow_catalog_app.command("add")
|
|
def workflow_catalog_add(
|
|
url: str = typer.Argument(..., help="Catalog URL to add"),
|
|
name: str = typer.Option(None, "--name", help="Catalog name"),
|
|
):
|
|
"""Add a workflow catalog source."""
|
|
from .workflows.catalog import WorkflowCatalog, WorkflowValidationError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = WorkflowCatalog(project_root)
|
|
try:
|
|
catalog.add_catalog(url, name)
|
|
except WorkflowValidationError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
console.print(f"[green]✓[/green] Catalog source added: {url}")
|
|
|
|
|
|
@workflow_catalog_app.command("remove")
|
|
def workflow_catalog_remove(
|
|
index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
|
|
):
|
|
"""Remove a workflow catalog source by index."""
|
|
from .workflows.catalog import WorkflowCatalog, WorkflowValidationError
|
|
|
|
project_root = _require_specify_project()
|
|
catalog = WorkflowCatalog(project_root)
|
|
try:
|
|
removed_name = catalog.remove_catalog(index)
|
|
except WorkflowValidationError as exc:
|
|
console.print(f"[red]Error:[/red] {exc}")
|
|
raise typer.Exit(1)
|
|
|
|
console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")
|
|
|
|
|
|
def main():
|
|
app()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|