From 163d299a475178dacb8feeb5fa775128e2efe16a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:21:21 -0500 Subject: [PATCH 1/2] chore: refactor root command adapters Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 141 ++---------------- src/specify_cli/command_check.py | 55 +++++++ .../{commands/init.py => command_init.py} | 46 +++--- src/specify_cli/command_version.py | 102 +++++++++++++ src/specify_cli/commands/__init__.py | 7 - src/specify_cli/integrations/_commands.py | 2 +- tests/integrations/test_cli.py | 22 +-- .../integrations/test_integration_alquimia.py | 4 +- tests/integrations/test_integration_claude.py | 4 +- .../test_agent_config.py} | 22 +-- tests/{ => specify_cli}/test_check_tool.py | 36 +---- tests/specify_cli/test_command_check.py | 39 +++++ .../test_command_init.py} | 41 ++++- .../test_command_version.py} | 27 +--- .../specify_cli/test_root_version_callback.py | 35 +++++ tests/test_live_transient_windows.py | 14 +- tests/test_presets.py | 12 +- 17 files changed, 330 insertions(+), 279 deletions(-) create mode 100644 src/specify_cli/command_check.py rename src/specify_cli/{commands/init.py => command_init.py} (97%) create mode 100644 src/specify_cli/command_version.py delete mode 100644 src/specify_cli/commands/__init__.py rename tests/{test_commands_package.py => specify_cli/test_agent_config.py} (82%) rename tests/{ => specify_cli}/test_check_tool.py (85%) create mode 100644 tests/specify_cli/test_command_check.py rename tests/{test_init_output_markup.py => specify_cli/test_command_init.py} (82%) rename tests/{test_cli_version.py => specify_cli/test_command_version.py} (80%) create mode 100644 tests/specify_cli/test_root_version_callback.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 95e023eed0..b05e962b44 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -28,13 +28,10 @@ import os import sys -import json from pathlib import Path import typer -from rich.panel import Panel from rich.align import Align -from rich.table import Table from .shared_infra import ( install_shared_infra as _install_shared_infra_impl, refresh_shared_templates as _refresh_shared_templates_impl, @@ -389,137 +386,19 @@ def _print_cli_warning( } -# ===== 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) +# ===== Root Commands ===== +from . import command_check as _command_check # noqa: E402 +from . import command_init as _command_init # noqa: E402 +from . import command_version as _command_version # noqa: E402 -@app.command() -def check(): - """Check that all required tools are installed.""" - show_banner() - console.print("[bold]Checking for installed tools...[/bold]\n") +_command_check.register(app) +_command_init.register(app) +_command_version.register(app) - tracker = StepTracker("Check Available Tools") - - agent_results = {} - for agent_key, agent_config in AGENT_CONFIG.items(): - if agent_key == "generic": - continue # Generic is not a real agent to check - agent_name = agent_config["name"] - requires_cli = agent_config["requires_cli"] - - tracker.add(agent_key, agent_name) - - if requires_cli: - agent_results[agent_key] = check_tool(agent_key, tracker=tracker) - else: - # IDE-based agent - skip CLI check and mark as optional - tracker.skip(agent_key, "IDE-based, no CLI check") - agent_results[agent_key] = False # Don't count IDE agents as "found" - - # Check VS Code variants (not in agent config) - tracker.add("code", "Visual Studio Code") - check_tool("code", tracker=tracker) - - tracker.add("code-insiders", "Visual Studio Code Insiders") - check_tool("code-insiders", tracker=tracker) - - console.print(tracker.render()) - - console.print("\n[bold green]Specify CLI is ready to use![/bold green]") - - if not any(agent_results.values()): - console.print("[dim]Tip: Install a coding agent for the best experience[/dim]") - - console.print("[dim]Tip: Run 'specify self check' to verify you have the latest CLI version[/dim]") - - -def _feature_capabilities() -> dict[str, bool]: - """Return stable local CLI capability flags for humans and agents.""" - return { - "controlled_multi_install_integrations": True, - "integration_use_command": True, - "multi_install_safe_registry_metadata": True, - "integration_upgrade_command": True, - "self_check_command": True, - "workflow_catalog": True, - "bundled_templates": True, - } - - -@app.command() -def version( - features: bool = typer.Option( - False, - "--features", - help="Show local CLI feature capabilities.", - ), - json_output: bool = typer.Option( - False, - "--json", - help="Emit feature capabilities as JSON. Requires --features.", - ), -): - """Display version and system information.""" - import platform - - cli_version = get_speckit_version() - - if json_output and not features: - console.print("[red]Error:[/red] --json requires --features.") - raise typer.Exit(1) - - if features: - capabilities = _feature_capabilities() - if json_output: - payload = {"version": cli_version, "features": capabilities} - console.print(json.dumps(payload, indent=2)) - return - - console.print(f"Spec Kit CLI: {cli_version}") - console.print() - console.print("Features:") - for key, enabled in capabilities.items(): - label = key.replace("_", " ") - console.print(f"- {label}: {'yes' if enabled else 'no'}") - return - - show_banner() - - info_table = Table(show_header=False, box=None, padding=(0, 2)) - info_table.add_column("Key", style="cyan", justify="right") - info_table.add_column("Value", style="white") - - info_table.add_row("CLI Version", cli_version) - info_table.add_row("", "") - info_table.add_row("Python", platform.python_version()) - info_table.add_row("Platform", platform.system()) - info_table.add_row("Architecture", platform.machine()) - info_table.add_row("OS Version", platform.version()) - # The OpenSSL runtime the interpreter actually loaded. HTTPS failure - # reports (#4433) hinge on which OpenSSL is in play, and on Windows it is - # not obvious from the outside, so surface it here. An interpreter built - # without the ssl extension skips the row rather than failing the command. - try: - import ssl - - openssl_version = getattr(ssl, "OPENSSL_VERSION", "") - except ImportError: - openssl_version = "" - if openssl_version: - info_table.add_row("OpenSSL", openssl_version) - - panel = Panel( - info_table, - title="[bold cyan]Specify CLI Information[/bold cyan]", - border_style="cyan", - padding=(1, 2) - ) - - console.print(panel) - console.print() +# Preserve root imports for handlers that were previously defined here. +check = _command_check.check +version = _command_version.version app.add_typer(_self_app, name="self") diff --git a/src/specify_cli/command_check.py b/src/specify_cli/command_check.py new file mode 100644 index 0000000000..6527427278 --- /dev/null +++ b/src/specify_cli/command_check.py @@ -0,0 +1,55 @@ +"""CLI adapter for ``specify check``.""" + +from __future__ import annotations + +import typer + +from ._agent_config import AGENT_CONFIG +from ._console import StepTracker, console, show_banner + + +def check() -> None: + """Check that all required tools are installed.""" + from . import check_tool + + show_banner() + console.print("[bold]Checking for installed tools...[/bold]\n") + + tracker = StepTracker("Check Available Tools") + + agent_results = {} + for agent_key, agent_config in AGENT_CONFIG.items(): + if agent_key == "generic": + continue + agent_name = agent_config["name"] + requires_cli = agent_config["requires_cli"] + + tracker.add(agent_key, agent_name) + + if requires_cli: + agent_results[agent_key] = check_tool(agent_key, tracker=tracker) + else: + tracker.skip(agent_key, "IDE-based, no CLI check") + agent_results[agent_key] = False + + tracker.add("code", "Visual Studio Code") + check_tool("code", tracker=tracker) + + tracker.add("code-insiders", "Visual Studio Code Insiders") + check_tool("code-insiders", tracker=tracker) + + console.print(tracker.render()) + + console.print("\n[bold green]Specify CLI is ready to use![/bold green]") + + if not any(agent_results.values()): + console.print("[dim]Tip: Install a coding agent for the best experience[/dim]") + + console.print( + "[dim]Tip: Run 'specify self check' to verify you have the latest CLI version[/dim]" + ) + + +def register(app: typer.Typer) -> None: + """Register ``specify check`` on the root application.""" + app.command()(check) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/command_init.py similarity index 97% rename from src/specify_cli/commands/init.py rename to src/specify_cli/command_init.py index 9cdd86d1d4..459551b36c 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/command_init.py @@ -1,4 +1,4 @@ -"""specify init command.""" +"""CLI adapter for ``specify init``.""" from __future__ import annotations @@ -15,18 +15,18 @@ from rich.markup import escape as _escape_markup from rich.panel import Panel -from .._agent_config import ( +from ._agent_config import ( AGENT_CONFIG, SCRIPT_TYPE_CHOICES, resolve_default_init_integration, ) -from .._assets import ( +from ._assets import ( _locate_bundled_preset, _locate_bundled_workflow, get_speckit_version, ) -from .._console import StepTracker, console, select_with_arrows, show_banner -from .._utils import check_tool +from ._console import StepTracker, console, select_with_arrows, show_banner +from ._utils import check_tool def _stdin_is_interactive() -> bool: @@ -108,9 +108,9 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve """ from urllib.parse import urlparse - from .._assets import _locate_bundled_extension - from ..extensions import ExtensionCatalog, ExtensionError, ExtensionManager - from ..extensions._commands import ( + from ._assets import _locate_bundled_extension + from .extensions import ExtensionCatalog, ExtensionError, ExtensionManager + from .extensions._commands import ( _resolve_catalog_extension, install_extension_from_url, ) @@ -164,7 +164,7 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve return f"{manifest.name} v{manifest.version} installed" if ext_info.get("bundled") and not ext_info.get("download_url"): - from ..extensions import REINSTALL_COMMAND + from .extensions import REINSTALL_COMMAND raise ValueError( f"Extension '{resolved_id}' is bundled with spec-kit but not found in the installed package. " @@ -219,7 +219,7 @@ def ensure_constitution_from_template( constitution) can seed the memory file. When nothing overrides it, the resolver falls through to the core template. """ - from ..presets import _materialize_constitution_template + from .presets import _materialize_constitution_template memory_constitution = project_path / ".specify" / "memory" / "constitution.md" @@ -380,24 +380,24 @@ def init( specify init my-project --extension https://example.com/extensions/my-ext.zip --trust-extension-urls # URL extension (non-interactive) """ # Lazy imports to avoid circular dependency — __init__.py imports this module - from .. import ( + from . import ( _install_shared_infra_or_exit, _print_cli_warning, ensure_executable_scripts, save_init_options, ) - from ..integration_runtime import ( + from .integration_runtime import ( invoke_prefix_for_integration as _invoke_prefix_for_integration, with_integration_setting as _with_integration_setting, ) - from ..integrations._commands import ( + from .integrations._commands import ( _parse_integration_options, _write_integration_json, ) show_banner() - from ..integrations import INTEGRATION_REGISTRY, get_integration + from .integrations import INTEGRATION_REGISTRY, get_integration if integration: resolved_integration = get_integration(integration) @@ -667,7 +667,7 @@ def init( ) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - from ..integrations.manifest import IntegrationManifest + from .integrations.manifest import IntegrationManifest tracker.start("integration") manifest = IntegrationManifest( @@ -684,7 +684,7 @@ def init( if extra: integration_parsed_options.update(extra) - from ..events import resolve_events + from .events import resolve_events events_map = resolve_events( resolved_integration.key, resolved_integration.config, @@ -702,7 +702,7 @@ def init( manifest.save() if force: - from ..integrations._helpers import ( + from .integrations._helpers import ( _register_extensions_for_agent, _register_presets_for_agent, ) @@ -769,8 +769,8 @@ def init( try: bundled_wf = _locate_bundled_workflow("speckit") if bundled_wf: - from ..workflows.catalog import WorkflowRegistry - from ..workflows.engine import WorkflowDefinition + from .workflows.catalog import WorkflowRegistry + from .workflows.engine import WorkflowDefinition wf_registry = WorkflowRegistry(project_path) if wf_registry.is_installed("speckit"): @@ -823,7 +823,7 @@ def init( if preset: try: - from ..presets import PresetCatalog, PresetError, PresetManager + from .presets import PresetCatalog, PresetError, PresetManager preset_manager = PresetManager(project_path) speckit_ver = get_speckit_version() @@ -849,7 +849,7 @@ def init( elif pack_info.get("bundled") and not pack_info.get( "download_url" ): - from ..extensions import REINSTALL_COMMAND + from .extensions import REINSTALL_COMMAND console.print( f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit " @@ -895,7 +895,7 @@ def init( # Install extensions specified via --extension if extensions: - from ..extensions._commands import _refresh_events_and_warn + from .extensions._commands import _refresh_events_and_warn speckit_ver = get_speckit_version() any_extension_installed = False @@ -1093,7 +1093,7 @@ def init( step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" - from .._invocation_style import ( + from ._invocation_style import ( is_dollar_skills_agent as _is_dollar_skills_agent, is_slash_skills_agent as _is_slash_skills_agent, ) diff --git a/src/specify_cli/command_version.py b/src/specify_cli/command_version.py new file mode 100644 index 0000000000..f09ad73acd --- /dev/null +++ b/src/specify_cli/command_version.py @@ -0,0 +1,102 @@ +"""CLI adapter for ``specify version``.""" + +from __future__ import annotations + +import json +import platform + +import typer +from rich.panel import Panel +from rich.table import Table + +from ._console import console, show_banner + + +def _feature_capabilities() -> dict[str, bool]: + """Return stable local CLI capability flags for humans and agents.""" + return { + "controlled_multi_install_integrations": True, + "integration_use_command": True, + "multi_install_safe_registry_metadata": True, + "integration_upgrade_command": True, + "self_check_command": True, + "workflow_catalog": True, + "bundled_templates": True, + } + + +def version( + features: bool = typer.Option( + False, + "--features", + help="Show local CLI feature capabilities.", + ), + json_output: bool = typer.Option( + False, + "--json", + help="Emit feature capabilities as JSON. Requires --features.", + ), +) -> None: + """Display version and system information.""" + from . import get_speckit_version + + cli_version = get_speckit_version() + + if json_output and not features: + console.print("[red]Error:[/red] --json requires --features.") + raise typer.Exit(1) + + if features: + capabilities = _feature_capabilities() + if json_output: + payload = {"version": cli_version, "features": capabilities} + console.print(json.dumps(payload, indent=2)) + return + + console.print(f"Spec Kit CLI: {cli_version}") + console.print() + console.print("Features:") + for key, enabled in capabilities.items(): + label = key.replace("_", " ") + console.print(f"- {label}: {'yes' if enabled else 'no'}") + return + + show_banner() + + info_table = Table(show_header=False, box=None, padding=(0, 2)) + info_table.add_column("Key", style="cyan", justify="right") + info_table.add_column("Value", style="white") + + info_table.add_row("CLI Version", cli_version) + info_table.add_row("", "") + info_table.add_row("Python", platform.python_version()) + info_table.add_row("Platform", platform.system()) + info_table.add_row("Architecture", platform.machine()) + info_table.add_row("OS Version", platform.version()) + # The OpenSSL runtime the interpreter actually loaded. HTTPS failure + # reports (#4433) hinge on which OpenSSL is in play, and on Windows it is + # not obvious from the outside, so surface it here. An interpreter built + # without the ssl extension skips the row rather than failing the command. + try: + import ssl + + openssl_version = getattr(ssl, "OPENSSL_VERSION", "") + except ImportError: + openssl_version = "" + if openssl_version: + info_table.add_row("OpenSSL", openssl_version) + + panel = Panel( + info_table, + title="[bold cyan]Specify CLI Information[/bold cyan]", + border_style="cyan", + padding=(1, 2), + ) + + console.print(panel) + console.print() + + +def register(app: typer.Typer) -> None: + """Register ``specify version`` on the root application.""" + app.command()(version) diff --git a/src/specify_cli/commands/__init__.py b/src/specify_cli/commands/__init__.py deleted file mode 100644 index 69ea28b9a6..0000000000 --- a/src/specify_cli/commands/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""CLI command groups extracted from the main application. - -Implemented command modules expose a ``register(app)`` function. Placeholder -modules are import-only anchors for command groups that still live in the main -application module. -""" -from __future__ import annotations diff --git a/src/specify_cli/integrations/_commands.py b/src/specify_cli/integrations/_commands.py index b8dd4542e7..ef90e576e9 100644 --- a/src/specify_cli/integrations/_commands.py +++ b/src/specify_cli/integrations/_commands.py @@ -10,7 +10,7 @@ from .._assets import get_speckit_version # noqa: F401 — re-exported for monkeypatching in tests from .catalog import catalog_app as integration_catalog_app # noqa: F401 — compatibility alias -# Re-export helpers used by commands/init.py and tests +# Re-export helpers used by command_init.py and tests from ._helpers import ( # noqa: F401 _cli_error_detail, _cli_phase_label, diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 678b24702d..ed6864253b 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -133,7 +133,7 @@ def test_noninteractive_flag_skips_pickers_when_stdin_is_a_tty( from typer.testing import CliRunner from specify_cli import app import specify_cli - import specify_cli.commands.init as init_mod + import specify_cli.command_init as init_mod monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) @@ -166,7 +166,7 @@ def test_noninteractive_flag_here_nonempty_requires_force( """ from typer.testing import CliRunner from specify_cli import app - import specify_cli.commands.init as init_mod + import specify_cli.command_init as init_mod monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) @@ -217,7 +217,7 @@ def test_noninteractive_flag_here_force_completes_without_script_flag( """ from typer.testing import CliRunner from specify_cli import app - import specify_cli.commands.init as init_mod + import specify_cli.command_init as init_mod monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) @@ -286,7 +286,7 @@ def test_interactive_init_picker_default_honors_env_var( # hardcoded constant (guards the picker wiring against regression). from typer.testing import CliRunner from specify_cli import app - import specify_cli.commands.init as init_mod + import specify_cli.command_init as init_mod monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini") @@ -347,7 +347,7 @@ def test_init_here_interactive_cancel_exits_zero(self, tmp_path, monkeypatch): an exit-1 --force error.""" from typer.testing import CliRunner from specify_cli import app - import specify_cli.commands.init as init_mod + import specify_cli.command_init as init_mod # Simulate an interactive terminal so the Abort is treated as a cancel. monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) @@ -1805,7 +1805,7 @@ def _run_init(self, tmp_path, args, project_name="ext-test"): # Patch get_speckit_version to return a stable (non-dev) version so that # the extension compatibility check (SpecifierSet(">=0.2.0")) passes. with patch( - "specify_cli.commands.init.get_speckit_version", + "specify_cli.command_init.get_speckit_version", return_value="0.8.2", ): result = runner.invoke(app, [ @@ -1837,7 +1837,7 @@ def test_catalog_extension_init_forwards_catalog_name(self, tmp_path, monkeypatc from types import SimpleNamespace import specify_cli._assets as assets - import specify_cli.commands.init as init_module + import specify_cli.command_init as init_module from specify_cli.extensions import ExtensionCatalog, ExtensionManager project = tmp_path / "project" @@ -2000,7 +2000,7 @@ def test_url_extension_skipped_without_trust(self, tmp_path): from unittest.mock import patch with patch( - "specify_cli.commands.init._stdin_is_interactive", return_value=False + "specify_cli.command_init._stdin_is_interactive", return_value=False ), patch("specify_cli.authentication.http.open_url") as mock_open: project, result = self._run_init( tmp_path, @@ -2025,7 +2025,7 @@ def test_noninteractive_flag_skips_url_trust_prompt_when_stdin_is_a_tty( """ from unittest.mock import patch - import specify_cli.commands.init as init_mod + import specify_cli.command_init as init_mod monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True) @@ -2070,7 +2070,7 @@ def test_noninteractive_flag_trust_urls_installs_without_confirm( from unittest.mock import patch from specify_cli import _locate_bundled_extension - import specify_cli.commands.init as init_mod + import specify_cli.command_init as init_mod bundled_git = _locate_bundled_extension("git") assert bundled_git is not None, "bundled git extension not found" @@ -2179,7 +2179,7 @@ def _open_download_zip(project_root, download_dir, zip_filename): return fd with patch( - "specify_cli.commands.init._stdin_is_interactive", return_value=True + "specify_cli.command_init._stdin_is_interactive", return_value=True ), patch("typer.confirm", return_value=True), patch( "specify_cli.authentication.http.open_url", return_value=FakeResponse(zip_bytes), diff --git a/tests/integrations/test_integration_alquimia.py b/tests/integrations/test_integration_alquimia.py index 6e8b6dbadd..aa4e856f69 100644 --- a/tests/integrations/test_integration_alquimia.py +++ b/tests/integrations/test_integration_alquimia.py @@ -203,10 +203,10 @@ def test_interactive_alquimia_selection_uses_integration_path(self, tmp_path): runner = CliRunner() with ( patch( - "specify_cli.commands.init._stdin_is_interactive", return_value=True + "specify_cli.command_init._stdin_is_interactive", return_value=True ), patch( - "specify_cli.commands.init.select_with_arrows", + "specify_cli.command_init.select_with_arrows", return_value="alquimia", ), ): diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 8b8067a8a0..8671b7c445 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -174,8 +174,8 @@ def test_interactive_claude_selection_uses_integration_path(self, tmp_path): os.chdir(project) runner = CliRunner() with ( - patch("specify_cli.commands.init._stdin_is_interactive", return_value=True), - patch("specify_cli.commands.init.select_with_arrows", return_value="claude"), + patch("specify_cli.command_init._stdin_is_interactive", return_value=True), + patch("specify_cli.command_init.select_with_arrows", return_value="claude"), ): result = runner.invoke( app, diff --git a/tests/test_commands_package.py b/tests/specify_cli/test_agent_config.py similarity index 82% rename from tests/test_commands_package.py rename to tests/specify_cli/test_agent_config.py index fd42eb8b75..6c58f0fde7 100644 --- a/tests/test_commands_package.py +++ b/tests/specify_cli/test_agent_config.py @@ -1,16 +1,4 @@ -"""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) +"""Tests for root agent configuration exports and defaults.""" def test_agent_config_importable(): @@ -44,14 +32,6 @@ def test_agent_config_re_exported_from_init(): 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 - - def test_resolve_default_init_integration_unset(monkeypatch): from specify_cli._agent_config import ( DEFAULT_INIT_INTEGRATION, diff --git a/tests/test_check_tool.py b/tests/specify_cli/test_check_tool.py similarity index 85% rename from tests/test_check_tool.py rename to tests/specify_cli/test_check_tool.py index ef8a9da2fb..0d82e84564 100644 --- a/tests/test_check_tool.py +++ b/tests/specify_cli/test_check_tool.py @@ -7,13 +7,7 @@ from unittest.mock import patch, MagicMock -from typer.testing import CliRunner - -from specify_cli import app, check_tool -from tests.conftest import strip_ansi - - -runner = CliRunner() +from specify_cli import check_tool class TestCheckToolClaude: @@ -155,31 +149,3 @@ def fake_which(name): ): run.return_value.returncode = 1 assert check_tool("docker-agent") is False - - -class TestCheckTip: - """`specify check` should point users to the existing version check.""" - - def test_check_shows_self_check_tip(self): - with patch("specify_cli.check_tool", return_value=True): - result = runner.invoke(app, ["check"]) - - output = strip_ansi(result.output) - assert result.exit_code == 0 - assert ( - "Tip: Run 'specify self check' to verify you have the latest CLI version" - in output - ) - - def test_check_tip_does_not_fetch_latest_release(self): - with ( - patch("specify_cli.check_tool", return_value=True), - patch( - "specify_cli._version._fetch_latest_release_tag", - side_effect=AssertionError("latest release lookup should not run"), - ) as fetch_latest, - ): - result = runner.invoke(app, ["check"]) - - assert result.exit_code == 0 - fetch_latest.assert_not_called() diff --git a/tests/specify_cli/test_command_check.py b/tests/specify_cli/test_command_check.py new file mode 100644 index 0000000000..2a49b64ccd --- /dev/null +++ b/tests/specify_cli/test_command_check.py @@ -0,0 +1,39 @@ +"""Tests for the ``specify check`` command adapter.""" + +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app +from tests.conftest import strip_ansi + + +runner = CliRunner() + + +class TestCheckTip: + """`specify check` should point users to the existing version check.""" + + def test_check_shows_self_check_tip(self): + with patch("specify_cli.check_tool", return_value=True): + result = runner.invoke(app, ["check"]) + + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert ( + "Tip: Run 'specify self check' to verify you have the latest CLI version" + in output + ) + + def test_check_tip_does_not_fetch_latest_release(self): + with ( + patch("specify_cli.check_tool", return_value=True), + patch( + "specify_cli._version._fetch_latest_release_tag", + side_effect=AssertionError("latest release lookup should not run"), + ) as fetch_latest, + ): + result = runner.invoke(app, ["check"]) + + assert result.exit_code == 0 + fetch_latest.assert_not_called() diff --git a/tests/test_init_output_markup.py b/tests/specify_cli/test_command_init.py similarity index 82% rename from tests/test_init_output_markup.py rename to tests/specify_cli/test_command_init.py index 54576fb33f..760541ca31 100644 --- a/tests/test_init_output_markup.py +++ b/tests/specify_cli/test_command_init.py @@ -1,6 +1,6 @@ -"""`specify init` must render user-supplied values literally, not as Rich markup. +"""Tests for the ``specify init`` command adapter. -`commands/init.py` interpolated the project name, `--integration`/`--script` +`command_init.py` interpolated the project name, `--integration`/`--script` values and paths straight into Rich markup f-strings. A name containing a tag-shaped bracket run was therefore consumed as markup: @@ -16,6 +16,7 @@ from __future__ import annotations +import importlib import os import re import subprocess @@ -25,13 +26,47 @@ from typer.testing import CliRunner from specify_cli import app -from specify_cli.commands.init import _shell_quote_arg +from specify_cli.command_init import _shell_quote_arg from tests.conftest import requires_bash _ANSI = re.compile(r"\x1b\[[0-9;]*m") +def test_command_init_importable(): + mod = importlib.import_module("specify_cli.command_init") + assert hasattr(mod, "register") + assert callable(mod.register) + + +def test_transitional_commands_package_removed(): + import specify_cli + + package_root = Path(specify_cli.__file__).parent + assert not (package_root / "commands" / "__init__.py").exists() + assert not (package_root / "commands" / "init.py").exists() + + +def test_init_command_registered(): + callback_names = [ + cmd.callback.__name__ for cmd in app.registered_commands if cmd.callback + ] + assert "init" in callback_names + + +def test_init_has_win32_guard(): + """init.py must assign _transient from platform check and pass it to Live.""" + import specify_cli + + init_src = Path(specify_cli.__file__).parent / "command_init.py" + content = init_src.read_text(encoding="utf-8") + guard = ( + r"_transient\s*=\s*sys\.platform\s*!=\s*['\"]win32['\"].*" + r"Live\(.*transient\s*=\s*_transient" + ) + assert re.search(guard, content, re.DOTALL) + + def _strip(text: str) -> str: return _ANSI.sub("", text or "") diff --git a/tests/test_cli_version.py b/tests/specify_cli/test_command_version.py similarity index 80% rename from tests/test_cli_version.py rename to tests/specify_cli/test_command_version.py index 3d8d54bae2..58a8e7c051 100644 --- a/tests/test_cli_version.py +++ b/tests/specify_cli/test_command_version.py @@ -1,4 +1,4 @@ -"""Tests for CLI version reporting.""" +"""Tests for the ``specify version`` command adapter.""" import json import sys @@ -14,31 +14,6 @@ runner = CliRunner() -class TestVersionFlag: - """Test --version / -V flag on the root command.""" - - def test_version_long_flag(self): - """specify --version prints version and exits 0.""" - with patch("specify_cli.get_speckit_version", return_value="1.2.3"): - result = runner.invoke(app, ["--version"]) - assert result.exit_code == 0 - assert "specify 1.2.3" in result.output - - def test_version_short_flag(self): - """specify -V prints version and exits 0.""" - with patch("specify_cli.get_speckit_version", return_value="1.2.3"): - result = runner.invoke(app, ["-V"]) - assert result.exit_code == 0 - assert "specify 1.2.3" in result.output - - def test_version_flag_takes_precedence_over_subcommand(self): - """--version should work even when a subcommand follows.""" - with patch("specify_cli.get_speckit_version", return_value="0.7.2"): - result = runner.invoke(app, ["--version", "init"]) - assert result.exit_code == 0 - assert "specify 0.7.2" in result.output - - class TestVersionCommand: """Test the `specify version` subcommand.""" diff --git a/tests/specify_cli/test_root_version_callback.py b/tests/specify_cli/test_root_version_callback.py new file mode 100644 index 0000000000..c24eba6ef7 --- /dev/null +++ b/tests/specify_cli/test_root_version_callback.py @@ -0,0 +1,35 @@ +"""Tests for the root ``--version`` callback option.""" + +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app + + +runner = CliRunner() + + +class TestVersionFlag: + """Test --version / -V flag on the root command.""" + + def test_version_long_flag(self): + """specify --version prints version and exits 0.""" + with patch("specify_cli.get_speckit_version", return_value="1.2.3"): + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "specify 1.2.3" in result.output + + def test_version_short_flag(self): + """specify -V prints version and exits 0.""" + with patch("specify_cli.get_speckit_version", return_value="1.2.3"): + result = runner.invoke(app, ["-V"]) + assert result.exit_code == 0 + assert "specify 1.2.3" in result.output + + def test_version_flag_takes_precedence_over_subcommand(self): + """--version should work even when a subcommand follows.""" + with patch("specify_cli.get_speckit_version", return_value="0.7.2"): + result = runner.invoke(app, ["--version", "init"]) + assert result.exit_code == 0 + assert "specify 0.7.2" in result.output diff --git a/tests/test_live_transient_windows.py b/tests/test_live_transient_windows.py index 4a45fb0cf2..d3810d3b17 100644 --- a/tests/test_live_transient_windows.py +++ b/tests/test_live_transient_windows.py @@ -11,7 +11,6 @@ from __future__ import annotations -from pathlib import Path from unittest.mock import MagicMock, patch @@ -64,27 +63,20 @@ def test_transient_true_on_macos(self): # --------------------------------------------------------------------------- -# init.py — verify source contains the platform guard (regression check) +# _console.py — verify source contains the platform guard (regression check) # --------------------------------------------------------------------------- -class TestSourceContainsPlatformGuard: +class TestConsoleSourceContainsPlatformGuard: """Ensure the platform guard feeds into the Live() transient kwarg.""" # Single DOTALL regex: _transient assigned from win32 check, then used in Live() _GUARD_RE = r"_transient\s*=\s*sys\.platform\s*!=\s*['\"]win32['\"].*Live\(.*transient\s*=\s*_transient" - def test_init_has_win32_guard(self): - """init.py must assign _transient from platform check and pass it to Live.""" - import re - - init_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "commands" / "init.py" - content = init_src.read_text(encoding="utf-8") - assert re.search(self._GUARD_RE, content, re.DOTALL) - def test_console_has_win32_guard(self): """_console.py must assign _transient from platform check and pass it to Live.""" import re + from pathlib import Path console_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "_console.py" content = console_src.read_text(encoding="utf-8") diff --git a/tests/test_presets.py b/tests/test_presets.py index df9f448ab0..a7e590b071 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4461,7 +4461,7 @@ def test_convention_constitution_removal_preserves_edited_content( self, project_dir, temp_dir ): """Removing a convention layer does not overwrite edited generated content.""" - from specify_cli.commands.init import ensure_constitution_from_template + from specify_cli.command_init import ensure_constitution_from_template templates_dir = project_dir / ".specify" / "templates" (templates_dir / "constitution-template.md").write_text("# Core Constitution\n") @@ -12722,7 +12722,7 @@ def _wrap_constitution_preset(self, temp_dir): return preset_dir def test_seeds_from_core_when_no_preset(self, project_dir): - from specify_cli.commands.init import ensure_constitution_from_template + from specify_cli.command_init import ensure_constitution_from_template self._core_constitution(project_dir) ensure_constitution_from_template(project_dir) @@ -12733,7 +12733,7 @@ def test_seeds_from_core_when_no_preset(self, project_dir): assert (memory.parent / ".constitution-template.json").exists() def test_seeds_from_preset_when_installed(self, project_dir): - from specify_cli.commands.init import ensure_constitution_from_template + from specify_cli.command_init import ensure_constitution_from_template self._core_constitution(project_dir) manager = PresetManager(project_dir) @@ -12750,7 +12750,7 @@ def test_seeds_from_preset_when_installed(self, project_dir): assert "[PROJECT_NAME]" not in content def test_preserves_existing_memory(self, project_dir): - from specify_cli.commands.init import ensure_constitution_from_template + from specify_cli.command_init import ensure_constitution_from_template self._core_constitution(project_dir) memory = project_dir / ".specify" / "memory" / "constitution.md" @@ -12763,7 +12763,7 @@ def test_preserves_existing_memory(self, project_dir): assert memory.read_text() == authored def test_preserves_edited_generated_memory(self, project_dir): - from specify_cli.commands.init import ensure_constitution_from_template + from specify_cli.command_init import ensure_constitution_from_template self._core_constitution(project_dir) ensure_constitution_from_template(project_dir) @@ -12777,7 +12777,7 @@ def test_preserves_edited_generated_memory(self, project_dir): assert memory.read_text() == authored def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir): - from specify_cli.commands.init import ensure_constitution_from_template + from specify_cli.command_init import ensure_constitution_from_template self._core_constitution(project_dir) manager = PresetManager(project_dir) From 8d9d50484a9cd9c8e1d1d2fc392c4df0775a2249 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:20:13 -0500 Subject: [PATCH 2/2] fix: preserve root command help order Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 2 +- tests/specify_cli/test_command_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b05e962b44..ca83890215 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -392,8 +392,8 @@ def _print_cli_warning( from . import command_init as _command_init # noqa: E402 from . import command_version as _command_version # noqa: E402 -_command_check.register(app) _command_init.register(app) +_command_check.register(app) _command_version.register(app) # Preserve root imports for handlers that were previously defined here. diff --git a/tests/specify_cli/test_command_init.py b/tests/specify_cli/test_command_init.py index 760541ca31..527b0b817a 100644 --- a/tests/specify_cli/test_command_init.py +++ b/tests/specify_cli/test_command_init.py @@ -51,7 +51,7 @@ def test_init_command_registered(): callback_names = [ cmd.callback.__name__ for cmd in app.registered_commands if cmd.callback ] - assert "init" in callback_names + assert callback_names == ["init", "check", "version"] def test_init_has_win32_guard():