Skip to content

Commit 163d299

Browse files
mnriemCopilot
andcommitted
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>
1 parent e03327f commit 163d299

17 files changed

Lines changed: 330 additions & 279 deletions

‎src/specify_cli/__init__.py‎

Lines changed: 10 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,10 @@
2828

2929
import os
3030
import sys
31-
import json
3231
from pathlib import Path
3332

3433
import typer
35-
from rich.panel import Panel
3634
from rich.align import Align
37-
from rich.table import Table
3835
from .shared_infra import (
3936
install_shared_infra as _install_shared_infra_impl,
4037
refresh_shared_templates as _refresh_shared_templates_impl,
@@ -389,137 +386,19 @@ def _print_cli_warning(
389386
}
390387

391388

392-
# ===== init command =====
393-
# Moved to commands/init.py — registered here to preserve CLI surface.
394-
from .commands import init as _init_cmd # noqa: E402
395-
_init_cmd.register(app)
389+
# ===== Root Commands =====
396390

391+
from . import command_check as _command_check # noqa: E402
392+
from . import command_init as _command_init # noqa: E402
393+
from . import command_version as _command_version # noqa: E402
397394

398-
@app.command()
399-
def check():
400-
"""Check that all required tools are installed."""
401-
show_banner()
402-
console.print("[bold]Checking for installed tools...[/bold]\n")
395+
_command_check.register(app)
396+
_command_init.register(app)
397+
_command_version.register(app)
403398

404-
tracker = StepTracker("Check Available Tools")
405-
406-
agent_results = {}
407-
for agent_key, agent_config in AGENT_CONFIG.items():
408-
if agent_key == "generic":
409-
continue # Generic is not a real agent to check
410-
agent_name = agent_config["name"]
411-
requires_cli = agent_config["requires_cli"]
412-
413-
tracker.add(agent_key, agent_name)
414-
415-
if requires_cli:
416-
agent_results[agent_key] = check_tool(agent_key, tracker=tracker)
417-
else:
418-
# IDE-based agent - skip CLI check and mark as optional
419-
tracker.skip(agent_key, "IDE-based, no CLI check")
420-
agent_results[agent_key] = False # Don't count IDE agents as "found"
421-
422-
# Check VS Code variants (not in agent config)
423-
tracker.add("code", "Visual Studio Code")
424-
check_tool("code", tracker=tracker)
425-
426-
tracker.add("code-insiders", "Visual Studio Code Insiders")
427-
check_tool("code-insiders", tracker=tracker)
428-
429-
console.print(tracker.render())
430-
431-
console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
432-
433-
if not any(agent_results.values()):
434-
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
435-
436-
console.print("[dim]Tip: Run 'specify self check' to verify you have the latest CLI version[/dim]")
437-
438-
439-
def _feature_capabilities() -> dict[str, bool]:
440-
"""Return stable local CLI capability flags for humans and agents."""
441-
return {
442-
"controlled_multi_install_integrations": True,
443-
"integration_use_command": True,
444-
"multi_install_safe_registry_metadata": True,
445-
"integration_upgrade_command": True,
446-
"self_check_command": True,
447-
"workflow_catalog": True,
448-
"bundled_templates": True,
449-
}
450-
451-
452-
@app.command()
453-
def version(
454-
features: bool = typer.Option(
455-
False,
456-
"--features",
457-
help="Show local CLI feature capabilities.",
458-
),
459-
json_output: bool = typer.Option(
460-
False,
461-
"--json",
462-
help="Emit feature capabilities as JSON. Requires --features.",
463-
),
464-
):
465-
"""Display version and system information."""
466-
import platform
467-
468-
cli_version = get_speckit_version()
469-
470-
if json_output and not features:
471-
console.print("[red]Error:[/red] --json requires --features.")
472-
raise typer.Exit(1)
473-
474-
if features:
475-
capabilities = _feature_capabilities()
476-
if json_output:
477-
payload = {"version": cli_version, "features": capabilities}
478-
console.print(json.dumps(payload, indent=2))
479-
return
480-
481-
console.print(f"Spec Kit CLI: {cli_version}")
482-
console.print()
483-
console.print("Features:")
484-
for key, enabled in capabilities.items():
485-
label = key.replace("_", " ")
486-
console.print(f"- {label}: {'yes' if enabled else 'no'}")
487-
return
488-
489-
show_banner()
490-
491-
info_table = Table(show_header=False, box=None, padding=(0, 2))
492-
info_table.add_column("Key", style="cyan", justify="right")
493-
info_table.add_column("Value", style="white")
494-
495-
info_table.add_row("CLI Version", cli_version)
496-
info_table.add_row("", "")
497-
info_table.add_row("Python", platform.python_version())
498-
info_table.add_row("Platform", platform.system())
499-
info_table.add_row("Architecture", platform.machine())
500-
info_table.add_row("OS Version", platform.version())
501-
# The OpenSSL runtime the interpreter actually loaded. HTTPS failure
502-
# reports (#4433) hinge on which OpenSSL is in play, and on Windows it is
503-
# not obvious from the outside, so surface it here. An interpreter built
504-
# without the ssl extension skips the row rather than failing the command.
505-
try:
506-
import ssl
507-
508-
openssl_version = getattr(ssl, "OPENSSL_VERSION", "")
509-
except ImportError:
510-
openssl_version = ""
511-
if openssl_version:
512-
info_table.add_row("OpenSSL", openssl_version)
513-
514-
panel = Panel(
515-
info_table,
516-
title="[bold cyan]Specify CLI Information[/bold cyan]",
517-
border_style="cyan",
518-
padding=(1, 2)
519-
)
520-
521-
console.print(panel)
522-
console.print()
399+
# Preserve root imports for handlers that were previously defined here.
400+
check = _command_check.check
401+
version = _command_version.version
523402

524403
app.add_typer(_self_app, name="self")
525404

‎src/specify_cli/command_check.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""CLI adapter for ``specify check``."""
2+
3+
from __future__ import annotations
4+
5+
import typer
6+
7+
from ._agent_config import AGENT_CONFIG
8+
from ._console import StepTracker, console, show_banner
9+
10+
11+
def check() -> None:
12+
"""Check that all required tools are installed."""
13+
from . import check_tool
14+
15+
show_banner()
16+
console.print("[bold]Checking for installed tools...[/bold]\n")
17+
18+
tracker = StepTracker("Check Available Tools")
19+
20+
agent_results = {}
21+
for agent_key, agent_config in AGENT_CONFIG.items():
22+
if agent_key == "generic":
23+
continue
24+
agent_name = agent_config["name"]
25+
requires_cli = agent_config["requires_cli"]
26+
27+
tracker.add(agent_key, agent_name)
28+
29+
if requires_cli:
30+
agent_results[agent_key] = check_tool(agent_key, tracker=tracker)
31+
else:
32+
tracker.skip(agent_key, "IDE-based, no CLI check")
33+
agent_results[agent_key] = False
34+
35+
tracker.add("code", "Visual Studio Code")
36+
check_tool("code", tracker=tracker)
37+
38+
tracker.add("code-insiders", "Visual Studio Code Insiders")
39+
check_tool("code-insiders", tracker=tracker)
40+
41+
console.print(tracker.render())
42+
43+
console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
44+
45+
if not any(agent_results.values()):
46+
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
47+
48+
console.print(
49+
"[dim]Tip: Run 'specify self check' to verify you have the latest CLI version[/dim]"
50+
)
51+
52+
53+
def register(app: typer.Typer) -> None:
54+
"""Register ``specify check`` on the root application."""
55+
app.command()(check)
Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""specify init command."""
1+
"""CLI adapter for ``specify init``."""
22

33
from __future__ import annotations
44

@@ -15,18 +15,18 @@
1515
from rich.markup import escape as _escape_markup
1616
from rich.panel import Panel
1717

18-
from .._agent_config import (
18+
from ._agent_config import (
1919
AGENT_CONFIG,
2020
SCRIPT_TYPE_CHOICES,
2121
resolve_default_init_integration,
2222
)
23-
from .._assets import (
23+
from ._assets import (
2424
_locate_bundled_preset,
2525
_locate_bundled_workflow,
2626
get_speckit_version,
2727
)
28-
from .._console import StepTracker, console, select_with_arrows, show_banner
29-
from .._utils import check_tool
28+
from ._console import StepTracker, console, select_with_arrows, show_banner
29+
from ._utils import check_tool
3030

3131

3232
def _stdin_is_interactive() -> bool:
@@ -108,9 +108,9 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve
108108
"""
109109
from urllib.parse import urlparse
110110

111-
from .._assets import _locate_bundled_extension
112-
from ..extensions import ExtensionCatalog, ExtensionError, ExtensionManager
113-
from ..extensions._commands import (
111+
from ._assets import _locate_bundled_extension
112+
from .extensions import ExtensionCatalog, ExtensionError, ExtensionManager
113+
from .extensions._commands import (
114114
_resolve_catalog_extension,
115115
install_extension_from_url,
116116
)
@@ -164,7 +164,7 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve
164164
return f"{manifest.name} v{manifest.version} installed"
165165

166166
if ext_info.get("bundled") and not ext_info.get("download_url"):
167-
from ..extensions import REINSTALL_COMMAND
167+
from .extensions import REINSTALL_COMMAND
168168

169169
raise ValueError(
170170
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(
219219
constitution) can seed the memory file. When nothing overrides it, the
220220
resolver falls through to the core template.
221221
"""
222-
from ..presets import _materialize_constitution_template
222+
from .presets import _materialize_constitution_template
223223

224224
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
225225

@@ -380,24 +380,24 @@ def init(
380380
specify init my-project --extension https://example.com/extensions/my-ext.zip --trust-extension-urls # URL extension (non-interactive)
381381
"""
382382
# Lazy imports to avoid circular dependency — __init__.py imports this module
383-
from .. import (
383+
from . import (
384384
_install_shared_infra_or_exit,
385385
_print_cli_warning,
386386
ensure_executable_scripts,
387387
save_init_options,
388388
)
389-
from ..integration_runtime import (
389+
from .integration_runtime import (
390390
invoke_prefix_for_integration as _invoke_prefix_for_integration,
391391
with_integration_setting as _with_integration_setting,
392392
)
393-
from ..integrations._commands import (
393+
from .integrations._commands import (
394394
_parse_integration_options,
395395
_write_integration_json,
396396
)
397397

398398
show_banner()
399399

400-
from ..integrations import INTEGRATION_REGISTRY, get_integration
400+
from .integrations import INTEGRATION_REGISTRY, get_integration
401401

402402
if integration:
403403
resolved_integration = get_integration(integration)
@@ -667,7 +667,7 @@ def init(
667667
) as live:
668668
tracker.attach_refresh(lambda: live.update(tracker.render()))
669669
try:
670-
from ..integrations.manifest import IntegrationManifest
670+
from .integrations.manifest import IntegrationManifest
671671

672672
tracker.start("integration")
673673
manifest = IntegrationManifest(
@@ -684,7 +684,7 @@ def init(
684684
if extra:
685685
integration_parsed_options.update(extra)
686686

687-
from ..events import resolve_events
687+
from .events import resolve_events
688688
events_map = resolve_events(
689689
resolved_integration.key,
690690
resolved_integration.config,
@@ -702,7 +702,7 @@ def init(
702702
manifest.save()
703703

704704
if force:
705-
from ..integrations._helpers import (
705+
from .integrations._helpers import (
706706
_register_extensions_for_agent,
707707
_register_presets_for_agent,
708708
)
@@ -769,8 +769,8 @@ def init(
769769
try:
770770
bundled_wf = _locate_bundled_workflow("speckit")
771771
if bundled_wf:
772-
from ..workflows.catalog import WorkflowRegistry
773-
from ..workflows.engine import WorkflowDefinition
772+
from .workflows.catalog import WorkflowRegistry
773+
from .workflows.engine import WorkflowDefinition
774774

775775
wf_registry = WorkflowRegistry(project_path)
776776
if wf_registry.is_installed("speckit"):
@@ -823,7 +823,7 @@ def init(
823823

824824
if preset:
825825
try:
826-
from ..presets import PresetCatalog, PresetError, PresetManager
826+
from .presets import PresetCatalog, PresetError, PresetManager
827827

828828
preset_manager = PresetManager(project_path)
829829
speckit_ver = get_speckit_version()
@@ -849,7 +849,7 @@ def init(
849849
elif pack_info.get("bundled") and not pack_info.get(
850850
"download_url"
851851
):
852-
from ..extensions import REINSTALL_COMMAND
852+
from .extensions import REINSTALL_COMMAND
853853

854854
console.print(
855855
f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
@@ -895,7 +895,7 @@ def init(
895895

896896
# Install extensions specified via --extension
897897
if extensions:
898-
from ..extensions._commands import _refresh_events_and_warn
898+
from .extensions._commands import _refresh_events_and_warn
899899

900900
speckit_ver = get_speckit_version()
901901
any_extension_installed = False
@@ -1093,7 +1093,7 @@ def init(
10931093
step_num += 1
10941094
usage_label = "skills" if native_skill_mode else "slash commands"
10951095

1096-
from .._invocation_style import (
1096+
from ._invocation_style import (
10971097
is_dollar_skills_agent as _is_dollar_skills_agent,
10981098
is_slash_skills_agent as _is_slash_skills_agent,
10991099
)

0 commit comments

Comments
 (0)