diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py
index 41fb994726..6050dec698 100644
--- a/src/specify_cli/__init__.py
+++ b/src/specify_cli/__init__.py
@@ -29,9 +29,7 @@
import os
import sys
import zipfile
-import shutil
import json
-import shlex
import yaml
from pathlib import Path
@@ -39,7 +37,6 @@
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 @@
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 @@
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"
@@ -442,676 +332,11 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
}
-@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
"[/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 --ai --ai-skills")
- raise typer.Exit(1)
+# ===== 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)
- 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
- 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")
-
- # 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:
- sanitized_wf = str(wf_err).replace('\n', ' ').strip()
- tracker.error("workflow", f"install failed: {sanitized_wf[: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:
- console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
- 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:
- console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
-
- 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():
diff --git a/src/specify_cli/_agent_config.py b/src/specify_cli/_agent_config.py
new file mode 100644
index 0000000000..e95439a458
--- /dev/null
+++ b/src/specify_cli/_agent_config.py
@@ -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"}
diff --git a/src/specify_cli/commands/__init__.py b/src/specify_cli/commands/__init__.py
new file mode 100644
index 0000000000..6cb4ed9568
--- /dev/null
+++ b/src/specify_cli/commands/__init__.py
@@ -0,0 +1,2 @@
+"""CLI command groups — each module exposes a register(app) function."""
+from __future__ import annotations
diff --git a/src/specify_cli/commands/extension.py b/src/specify_cli/commands/extension.py
new file mode 100644
index 0000000000..f40a2c8f1f
--- /dev/null
+++ b/src/specify_cli/commands/extension.py
@@ -0,0 +1,2 @@
+"""specify extension * commands — placeholder for future extraction."""
+from __future__ import annotations
diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py
new file mode 100644
index 0000000000..68d53243b8
--- /dev/null
+++ b/src/specify_cli/commands/init.py
@@ -0,0 +1,738 @@
+"""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 (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
+ """
+ # Lazy imports to avoid circular dependency — __init__.py imports this module
+ from .. import (
+ _install_shared_infra_or_exit,
+ _parse_integration_options,
+ _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 "[/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 --ai --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")
+
+ 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:
+ 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:
+ console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
+ finally:
+ if zip_path is not None:
+ try:
+ zip_path.unlink(missing_ok=True)
+ except OSError:
+ pass
+ except Exception as preset_err:
+ console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
+
+ 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)
diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py
new file mode 100644
index 0000000000..a42fbaaea9
--- /dev/null
+++ b/src/specify_cli/commands/integration.py
@@ -0,0 +1,2 @@
+"""specify integration * commands — placeholder for future extraction."""
+from __future__ import annotations
diff --git a/src/specify_cli/commands/preset.py b/src/specify_cli/commands/preset.py
new file mode 100644
index 0000000000..510415af1c
--- /dev/null
+++ b/src/specify_cli/commands/preset.py
@@ -0,0 +1,2 @@
+"""specify preset * commands — placeholder for future extraction."""
+from __future__ import annotations
diff --git a/src/specify_cli/commands/workflow.py b/src/specify_cli/commands/workflow.py
new file mode 100644
index 0000000000..3fa1f48ddf
--- /dev/null
+++ b/src/specify_cli/commands/workflow.py
@@ -0,0 +1,2 @@
+"""specify workflow * commands — placeholder for future extraction."""
+from __future__ import annotations
diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py
index 142db0dd92..98f31e5935 100644
--- a/tests/integrations/test_integration_claude.py
+++ b/tests/integrations/test_integration_claude.py
@@ -197,8 +197,8 @@ def test_interactive_claude_selection_uses_integration_path(self, tmp_path):
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,
diff --git a/tests/test_commands_package.py b/tests/test_commands_package.py
new file mode 100644
index 0000000000..e5aeb4fbf4
--- /dev/null
+++ b/tests/test_commands_package.py
@@ -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