From f0e27fb62720c0074ba44ded85402d3843cb3c66 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Mon, 18 May 2026 11:55:07 +0800 Subject: [PATCH 1/4] 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 --- src/specify_cli/__init__.py | 794 +----------------------- src/specify_cli/_agent_config.py | 43 ++ src/specify_cli/commands/__init__.py | 1 + src/specify_cli/commands/extension.py | 1 + src/specify_cli/commands/init.py | 736 ++++++++++++++++++++++ src/specify_cli/commands/integration.py | 1 + src/specify_cli/commands/preset.py | 1 + src/specify_cli/commands/workflow.py | 1 + tests/test_commands_package.py | 48 ++ 9 files changed, 843 insertions(+), 783 deletions(-) create mode 100644 src/specify_cli/_agent_config.py create mode 100644 src/specify_cli/commands/__init__.py create mode 100644 src/specify_cli/commands/extension.py create mode 100644 src/specify_cli/commands/init.py create mode 100644 src/specify_cli/commands/integration.py create mode 100644 src/specify_cli/commands/preset.py create mode 100644 src/specify_cli/commands/workflow.py create mode 100644 tests/test_commands_package.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 41fb994726..3d82f5c51d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -98,84 +98,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 +278,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 +335,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..b7c14d45cc --- /dev/null +++ b/src/specify_cli/_agent_config.py @@ -0,0 +1,43 @@ +"""Agent configuration constants derived from the integration registry.""" +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..f21eea0d47 --- /dev/null +++ b/src/specify_cli/commands/__init__.py @@ -0,0 +1 @@ +"""CLI command groups — each module exposes a register(app) function.""" diff --git a/src/specify_cli/commands/extension.py b/src/specify_cli/commands/extension.py new file mode 100644 index 0000000000..25809fb7a5 --- /dev/null +++ b/src/specify_cli/commands/extension.py @@ -0,0 +1 @@ +"""specify extension * commands — placeholder for future extraction.""" diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py new file mode 100644 index 0000000000..8775ceb4f3 --- /dev/null +++ b/src/specify_cli/commands/init.py @@ -0,0 +1,736 @@ +"""specify init command.""" +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..5a4a9674a2 --- /dev/null +++ b/src/specify_cli/commands/integration.py @@ -0,0 +1 @@ +"""specify integration * commands — placeholder for future extraction.""" diff --git a/src/specify_cli/commands/preset.py b/src/specify_cli/commands/preset.py new file mode 100644 index 0000000000..4de63c0ab0 --- /dev/null +++ b/src/specify_cli/commands/preset.py @@ -0,0 +1 @@ +"""specify preset * commands — placeholder for future extraction.""" diff --git a/src/specify_cli/commands/workflow.py b/src/specify_cli/commands/workflow.py new file mode 100644 index 0000000000..2a113f226b --- /dev/null +++ b/src/specify_cli/commands/workflow.py @@ -0,0 +1 @@ +"""specify workflow * commands — placeholder for future extraction.""" 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 From a3e878ce5c07a05da3fcb4b0f783249f45d167b4 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Mon, 18 May 2026 13:14:52 +0800 Subject: [PATCH 2/4] 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. --- tests/integrations/test_integration_claude.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, From ea0ccf63f96a16c946e7f9a3b4c80c4552edbc12 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Mon, 18 May 2026 13:24:53 +0800 Subject: [PATCH 3/4] 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 --- src/specify_cli/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 3d82f5c51d..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, From b3c5c97931a97d71da572091d218273337a52cac Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Mon, 18 May 2026 13:33:22 +0800 Subject: [PATCH 4/4] 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. --- src/specify_cli/_agent_config.py | 2 ++ src/specify_cli/commands/__init__.py | 1 + src/specify_cli/commands/extension.py | 1 + src/specify_cli/commands/init.py | 2 ++ src/specify_cli/commands/integration.py | 1 + src/specify_cli/commands/preset.py | 1 + src/specify_cli/commands/workflow.py | 1 + 7 files changed, 9 insertions(+) diff --git a/src/specify_cli/_agent_config.py b/src/specify_cli/_agent_config.py index b7c14d45cc..e95439a458 100644 --- a/src/specify_cli/_agent_config.py +++ b/src/specify_cli/_agent_config.py @@ -1,4 +1,6 @@ """Agent configuration constants derived from the integration registry.""" +from __future__ import annotations + from typing import Any diff --git a/src/specify_cli/commands/__init__.py b/src/specify_cli/commands/__init__.py index f21eea0d47..6cb4ed9568 100644 --- a/src/specify_cli/commands/__init__.py +++ b/src/specify_cli/commands/__init__.py @@ -1 +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 index 25809fb7a5..f40a2c8f1f 100644 --- a/src/specify_cli/commands/extension.py +++ b/src/specify_cli/commands/extension.py @@ -1 +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 index 8775ceb4f3..68d53243b8 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -1,4 +1,6 @@ """specify init command.""" +from __future__ import annotations + import os import shlex import shutil diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py index 5a4a9674a2..a42fbaaea9 100644 --- a/src/specify_cli/commands/integration.py +++ b/src/specify_cli/commands/integration.py @@ -1 +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 index 4de63c0ab0..510415af1c 100644 --- a/src/specify_cli/commands/preset.py +++ b/src/specify_cli/commands/preset.py @@ -1 +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 index 2a113f226b..3fa1f48ddf 100644 --- a/src/specify_cli/commands/workflow.py +++ b/src/specify_cli/commands/workflow.py @@ -1 +1,2 @@ """specify workflow * commands — placeholder for future extraction.""" +from __future__ import annotations