mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
refactor: create commands/ package and move init handler (PR-4/8) (#2615)
* refactor: create commands/ package and move init handler (PR-4/8) - Extract agent configuration constants (AGENT_CONFIG, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES, etc.) to _agent_config.py to avoid circular imports - Create commands/ package skeleton with stub modules for each command group - Move init command handler (~670 lines) from __init__.py to commands/init.py using the register(app) pattern; lazy imports inside the handler body prevent circular dependencies with __init__.py - Re-export AGENT_CONFIG, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES from __init__.py for backward compatibility - Add tests/test_commands_package.py to verify package structure * fix(tests): update patch targets after moving init handler to commands/init.py _stdin_is_interactive and select_with_arrows are now bound in specify_cli.commands.init, not specify_cli directly. * fix(lint): remove unused imports and mark re-exports in __init__.py - Remove shutil, shlex top-level imports (used lazily inside functions) - Remove rich.live.Live import (moved to commands/init.py) - Mark select_with_arrows and _locate_bundled_workflow as explicit re-exports to satisfy ruff F401 * chore: add from __future__ import annotations to new modules Aligns with the project convention established in _console.py, _assets.py, _utils.py, and other modules. * docs(cli): align init help with bundled scaffolding Potential fix for pull request finding Update command package documentation and init help text to reflect the current implementation: init uses bundled assets and integration setup, while placeholder command modules are import anchors until extracted. Remove the unused tracker-active flag assignment that had no reader in the codebase. Constraint: --offline is hidden/no-op and init no longer downloads templates from GitHub releases Rejected: Add no-op register functions to placeholder modules | would imply extracted command groups are implemented there Confidence: high Scope-risk: narrow Directive: Keep CLI help text aligned with the actual init scaffolding path Tested: uv run specify init --help; uv run pytest tests/test_commands_package.py tests/test_agent_config_consistency.py -q; uv run pytest tests/test_commands_package.py tests/test_console_imports.py tests/integrations/test_cli.py -q Not-tested: full test suite * fix(init): align preset failure reporting with _print_cli_warning helper Use the _print_cli_warning helper (introduced in main) for preset install failures so that output matches the expected format: "Failed to install preset '<name>': ..." "Continuing without the optional preset." * fix(init): remove unused lazy imports The init command imported CLI error formatting helpers through its circular-dependency-safe lazy import block, but the module does not use them. Remove those imports so ruff does not report F401. Constraint: uvx ruff check src/ must pass. Rejected: Wire the helpers into init error handling | Existing preset warnings already use _print_cli_warning, and changing behavior is unnecessary for this lint fix. Confidence: high Scope-risk: narrow Directive: Keep lazy import blocks limited to names consumed in the importing module. Tested: uvx ruff check src/ Not-tested: Full pytest suite Co-authored-by: OmX <omx@oh-my-codex.dev> --------- Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
@@ -29,9 +29,7 @@ Or install globally:
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import shutil
|
||||
import json
|
||||
import shlex
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
@@ -39,7 +37,6 @@ 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 (
|
||||
@@ -70,13 +67,13 @@ from ._console import (
|
||||
StepTracker,
|
||||
console,
|
||||
get_key as get_key,
|
||||
select_with_arrows,
|
||||
select_with_arrows as select_with_arrows,
|
||||
show_banner,
|
||||
)
|
||||
from ._assets import (
|
||||
_locate_bundled_extension,
|
||||
_locate_bundled_preset,
|
||||
_locate_bundled_workflow,
|
||||
_locate_bundled_workflow as _locate_bundled_workflow,
|
||||
_locate_core_pack,
|
||||
_repo_root,
|
||||
get_speckit_version as get_speckit_version,
|
||||
@@ -98,84 +95,13 @@ from ._version import (
|
||||
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"}
|
||||
from ._agent_config import (
|
||||
AGENT_CONFIG as AGENT_CONFIG,
|
||||
AI_ASSISTANT_ALIASES as AI_ASSISTANT_ALIASES,
|
||||
AI_ASSISTANT_HELP as AI_ASSISTANT_HELP,
|
||||
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
|
||||
app = typer.Typer(
|
||||
name="specify",
|
||||
@@ -349,42 +275,6 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
|
||||
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"
|
||||
|
||||
|
||||
@@ -471,690 +361,11 @@ SKILL_DESCRIPTIONS = {
|
||||
}
|
||||
|
||||
|
||||
@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.
|
||||
# ===== init command =====
|
||||
# Moved to commands/init.py — registered here to preserve CLI surface.
|
||||
from .commands import init as _init_cmd # noqa: E402
|
||||
_init_cmd.register(app)
|
||||
|
||||
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():
|
||||
|
||||
45
src/specify_cli/_agent_config.py
Normal file
45
src/specify_cli/_agent_config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Agent configuration constants derived from the integration registry."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _build_agent_config() -> dict[str, dict[str, Any]]:
|
||||
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: dict[str, dict[str, Any]] = _build_agent_config()
|
||||
|
||||
DEFAULT_INIT_INTEGRATION = "copilot"
|
||||
|
||||
AI_ASSISTANT_ALIASES: dict[str, str] = {
|
||||
"kiro": "kiro-cli",
|
||||
}
|
||||
|
||||
|
||||
def _build_ai_assistant_help() -> str:
|
||||
non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic")
|
||||
base_help = (
|
||||
f"AI assistant to use: {', '.join(non_generic_agents)}, "
|
||||
"or generic (requires --ai-commands-dir)."
|
||||
)
|
||||
if not AI_ASSISTANT_ALIASES:
|
||||
return base_help
|
||||
alias_phrases = []
|
||||
for alias, target in sorted(AI_ASSISTANT_ALIASES.items()):
|
||||
alias_phrases.append(f"'{alias}' as an alias for '{target}'")
|
||||
if len(alias_phrases) == 1:
|
||||
aliases_text = alias_phrases[0]
|
||||
else:
|
||||
aliases_text = ", ".join(alias_phrases[:-1]) + " and " + alias_phrases[-1]
|
||||
return base_help + " Use " + aliases_text + "."
|
||||
|
||||
|
||||
AI_ASSISTANT_HELP: str = _build_ai_assistant_help()
|
||||
|
||||
SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
||||
7
src/specify_cli/commands/__init__.py
Normal file
7
src/specify_cli/commands/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""CLI command groups extracted from the main application.
|
||||
|
||||
Implemented command modules expose a ``register(app)`` function. Placeholder
|
||||
modules are import-only anchors for command groups that still live in the main
|
||||
application module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
2
src/specify_cli/commands/extension.py
Normal file
2
src/specify_cli/commands/extension.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""specify extension * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
743
src/specify_cli/commands/init.py
Normal file
743
src/specify_cli/commands/init.py
Normal file
@@ -0,0 +1,743 @@
|
||||
"""specify init command."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
|
||||
from .._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
AI_ASSISTANT_ALIASES,
|
||||
AI_ASSISTANT_HELP,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
from .._assets import (
|
||||
_locate_bundled_extension,
|
||||
_locate_bundled_preset,
|
||||
_locate_bundled_workflow,
|
||||
get_speckit_version,
|
||||
)
|
||||
from .._console import StepTracker, console, select_with_arrows, show_banner
|
||||
from .._utils import check_tool, init_git_repo, is_git_repo
|
||||
|
||||
def _build_integration_equivalent(
|
||||
integration_key: str,
|
||||
ai_commands_dir: str | None = None,
|
||||
) -> str:
|
||||
parts = [f"--integration {integration_key}"]
|
||||
if integration_key == "generic" and ai_commands_dir:
|
||||
parts.append(
|
||||
f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"'
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _build_ai_deprecation_warning(
|
||||
integration_key: str,
|
||||
ai_commands_dir: str | None = None,
|
||||
) -> str:
|
||||
replacement = _build_integration_equivalent(
|
||||
integration_key,
|
||||
ai_commands_dir=ai_commands_dir,
|
||||
)
|
||||
return (
|
||||
"[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n"
|
||||
f"Use [bold]{replacement}[/bold] instead."
|
||||
)
|
||||
|
||||
|
||||
def _stdin_is_interactive() -> bool:
|
||||
return sys.stdin.isatty()
|
||||
|
||||
|
||||
def ensure_constitution_from_template(
|
||||
project_path: Path, tracker: StepTracker | None = None
|
||||
) -> None:
|
||||
"""Copy constitution template to memory if it doesn't exist."""
|
||||
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
|
||||
template_constitution = project_path / ".specify" / "templates" / "constitution-template.md"
|
||||
|
||||
if memory_constitution.exists():
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.skip("constitution", "existing file preserved")
|
||||
return
|
||||
|
||||
if not template_constitution.exists():
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.error("constitution", "template not found")
|
||||
return
|
||||
|
||||
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]")
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
@app.command()
|
||||
def init(
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||
ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
|
||||
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
|
||||
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
|
||||
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
||||
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. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", hidden=True),
|
||||
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
|
||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
||||
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
|
||||
integration_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.
|
||||
|
||||
Project files are scaffolded from assets bundled inside the specify-cli
|
||||
package, so initialization does not need network access and templates
|
||||
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. Install bundled Spec Kit templates, scripts, workflow, and shared
|
||||
project infrastructure
|
||||
4. Initialize a fresh git repository (if not --no-git and no existing repo)
|
||||
5. Set up coding agent integration commands and optional presets
|
||||
|
||||
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
|
||||
"""
|
||||
# Lazy imports to avoid circular dependency — __init__.py imports this module
|
||||
from .. import (
|
||||
_install_shared_infra_or_exit,
|
||||
_parse_integration_options,
|
||||
_print_cli_warning,
|
||||
_write_integration_json,
|
||||
ensure_executable_scripts,
|
||||
save_init_options,
|
||||
)
|
||||
from ..integration_runtime import with_integration_setting as _with_integration_setting
|
||||
|
||||
show_banner()
|
||||
ai_deprecation_warning: str | None = None
|
||||
|
||||
if ai_assistant and ai_assistant.startswith("--"):
|
||||
console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
|
||||
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?")
|
||||
console.print("[yellow]Example:[/yellow] specify init --integration claude --here")
|
||||
console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_commands_dir and ai_commands_dir.startswith("--"):
|
||||
console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'")
|
||||
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?")
|
||||
console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_assistant:
|
||||
ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant)
|
||||
|
||||
if integration and ai_assistant:
|
||||
console.print("[red]Error:[/red] --integration and --ai are mutually exclusive")
|
||||
raise typer.Exit(1)
|
||||
|
||||
from ..integrations import INTEGRATION_REGISTRY, get_integration
|
||||
if integration:
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
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,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
|
||||
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:
|
||||
from ..integrations.manifest import IntegrationManifest
|
||||
tracker.start("integration")
|
||||
manifest = IntegrationManifest(
|
||||
resolved_integration.key, project_path, version=get_speckit_version()
|
||||
)
|
||||
|
||||
integration_parsed_options: dict[str, Any] = {}
|
||||
if ai_commands_dir:
|
||||
integration_parsed_options["commands_dir"] = ai_commands_dir
|
||||
if ai_skills:
|
||||
integration_parsed_options["skills"] = True
|
||||
if integration_options:
|
||||
extra = _parse_integration_options(resolved_integration, integration_options)
|
||||
if extra:
|
||||
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))
|
||||
|
||||
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
|
||||
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
|
||||
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")
|
||||
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
|
||||
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
|
||||
git_messages.append(
|
||||
f"extension install failed: {sanitized_ext[: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")
|
||||
|
||||
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:
|
||||
sanitized_wf = str(wf_err).replace('\n', ' ').strip()
|
||||
tracker.error("workflow", f"install failed: {sanitized_wf[:120]}")
|
||||
|
||||
ensure_executable_scripts(project_path, tracker=tracker)
|
||||
|
||||
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(),
|
||||
}
|
||||
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)
|
||||
|
||||
if preset:
|
||||
try:
|
||||
from ..presets import PresetManager, PresetCatalog, PresetError
|
||||
preset_manager = PresetManager(project_path)
|
||||
speckit_ver = get_speckit_version()
|
||||
|
||||
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:
|
||||
try:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as preset_err:
|
||||
_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_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
|
||||
|
||||
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:
|
||||
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)
|
||||
2
src/specify_cli/commands/integration.py
Normal file
2
src/specify_cli/commands/integration.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""specify integration * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
2
src/specify_cli/commands/preset.py
Normal file
2
src/specify_cli/commands/preset.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""specify preset * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
2
src/specify_cli/commands/workflow.py
Normal file
2
src/specify_cli/commands/workflow.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""specify workflow * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
@@ -197,8 +197,8 @@ class TestClaudeIntegration:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("specify_cli._stdin_is_interactive", return_value=True),
|
||||
patch("specify_cli.select_with_arrows", return_value="claude"),
|
||||
patch("specify_cli.commands.init._stdin_is_interactive", return_value=True),
|
||||
patch("specify_cli.commands.init.select_with_arrows", return_value="claude"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
|
||||
48
tests/test_commands_package.py
Normal file
48
tests/test_commands_package.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Tests for the commands/ package structure."""
|
||||
import importlib
|
||||
|
||||
|
||||
def test_commands_package_importable():
|
||||
mod = importlib.import_module("specify_cli.commands")
|
||||
assert mod is not None
|
||||
|
||||
|
||||
def test_commands_init_importable():
|
||||
mod = importlib.import_module("specify_cli.commands.init")
|
||||
assert hasattr(mod, "register")
|
||||
assert callable(mod.register)
|
||||
|
||||
|
||||
def test_commands_stubs_importable():
|
||||
for name in ("integration", "preset", "extension", "workflow"):
|
||||
mod = importlib.import_module(f"specify_cli.commands.{name}")
|
||||
assert mod is not None
|
||||
|
||||
|
||||
def test_agent_config_importable():
|
||||
from specify_cli._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
AI_ASSISTANT_ALIASES,
|
||||
AI_ASSISTANT_HELP,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert isinstance(AI_ASSISTANT_ALIASES, dict)
|
||||
assert isinstance(AI_ASSISTANT_HELP, str)
|
||||
assert DEFAULT_INIT_INTEGRATION == "copilot"
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
def test_agent_config_re_exported_from_init():
|
||||
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
def test_init_command_registered():
|
||||
from specify_cli import app
|
||||
callback_names = [
|
||||
cmd.callback.__name__ for cmd in app.registered_commands if cmd.callback
|
||||
]
|
||||
assert "init" in callback_names
|
||||
Reference in New Issue
Block a user