diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 325692900e..20295b67b4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -45,15 +45,10 @@ from typing import Any, Optional import typer -from rich.console import Console from rich.panel import Panel -from rich.text import Text from rich.live import Live from rich.align import Align from rich.table import Table -from rich.tree import Tree -from typer.core import TyperGroup - from .integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -75,8 +70,16 @@ refresh_shared_templates as _refresh_shared_templates_impl, ) -# For cross-platform keyboard input -import readchar +from ._console import ( + BANNER, + TAGLINE, + BannerGroup, + StepTracker, + console, + get_key, + select_with_arrows, + show_banner, +) GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" @@ -161,207 +164,6 @@ def _stdin_is_interactive() -> bool: CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" -BANNER = """ -███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ -██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝ -███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝ -╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝ -███████║██║ ███████╗╚██████╗██║██║ ██║ -╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝ -""" - -TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit" -class StepTracker: - """Track and render hierarchical steps without emojis, similar to Claude Code tree output. - Supports live auto-refresh via an attached refresh callback. - """ - def __init__(self, title: str): - self.title = title - self.steps = [] # list of dicts: {key, label, status, detail} - self.status_order = {"pending": 0, "running": 1, "done": 2, "error": 3, "skipped": 4} - self._refresh_cb = None # callable to trigger UI refresh - - def attach_refresh(self, cb): - self._refresh_cb = cb - - def add(self, key: str, label: str): - if key not in [s["key"] for s in self.steps]: - self.steps.append({"key": key, "label": label, "status": "pending", "detail": ""}) - self._maybe_refresh() - - def start(self, key: str, detail: str = ""): - self._update(key, status="running", detail=detail) - - def complete(self, key: str, detail: str = ""): - self._update(key, status="done", detail=detail) - - def error(self, key: str, detail: str = ""): - self._update(key, status="error", detail=detail) - - def skip(self, key: str, detail: str = ""): - self._update(key, status="skipped", detail=detail) - - def _update(self, key: str, status: str, detail: str): - for s in self.steps: - if s["key"] == key: - s["status"] = status - if detail: - s["detail"] = detail - self._maybe_refresh() - return - - self.steps.append({"key": key, "label": key, "status": status, "detail": detail}) - self._maybe_refresh() - - def _maybe_refresh(self): - if self._refresh_cb: - try: - self._refresh_cb() - except Exception: - pass - - def render(self): - tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50") - for step in self.steps: - label = step["label"] - detail_text = step["detail"].strip() if step["detail"] else "" - - status = step["status"] - if status == "done": - symbol = "[green]●[/green]" - elif status == "pending": - symbol = "[green dim]○[/green dim]" - elif status == "running": - symbol = "[cyan]○[/cyan]" - elif status == "error": - symbol = "[red]●[/red]" - elif status == "skipped": - symbol = "[yellow]○[/yellow]" - else: - symbol = " " - - if status == "pending": - # Entire line light gray (pending) - if detail_text: - line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" - else: - line = f"{symbol} [bright_black]{label}[/bright_black]" - else: - # Label white, detail (if any) light gray in parentheses - if detail_text: - line = f"{symbol} [white]{label}[/white] [bright_black]({detail_text})[/bright_black]" - else: - line = f"{symbol} [white]{label}[/white]" - - tree.add(line) - return tree - -def get_key(): - """Get a single keypress in a cross-platform way using readchar.""" - key = readchar.readkey() - - if key == readchar.key.UP or key == readchar.key.CTRL_P: - return 'up' - if key == readchar.key.DOWN or key == readchar.key.CTRL_N: - return 'down' - - if key == readchar.key.ENTER: - return 'enter' - - if key == readchar.key.ESC: - return 'escape' - - if key == readchar.key.CTRL_C: - raise KeyboardInterrupt - - return key - -def select_with_arrows(options: dict, prompt_text: str = "Select an option", default_key: str = None) -> str: - """ - Interactive selection using arrow keys with Rich Live display. - - Args: - options: Dict with keys as option keys and values as descriptions - prompt_text: Text to show above the options - default_key: Default option key to start with - - Returns: - Selected option key - """ - option_keys = list(options.keys()) - if default_key and default_key in option_keys: - selected_index = option_keys.index(default_key) - else: - selected_index = 0 - - selected_key = None - - def create_selection_panel(): - """Create the selection panel with current selection highlighted.""" - table = Table.grid(padding=(0, 2)) - table.add_column(style="cyan", justify="left", width=3) - table.add_column(style="white", justify="left") - - for i, key in enumerate(option_keys): - if i == selected_index: - table.add_row("▶", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") - else: - table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") - - table.add_row("", "") - table.add_row("", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]") - - return Panel( - table, - title=f"[bold]{prompt_text}[/bold]", - border_style="cyan", - padding=(1, 2) - ) - - console.print() - - def run_selection_loop(): - nonlocal selected_key, selected_index - with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live: - while True: - try: - key = get_key() - if key == 'up': - selected_index = (selected_index - 1) % len(option_keys) - elif key == 'down': - selected_index = (selected_index + 1) % len(option_keys) - elif key == 'enter': - selected_key = option_keys[selected_index] - break - elif key == 'escape': - console.print("\n[yellow]Selection cancelled[/yellow]") - raise typer.Exit(1) - - live.update(create_selection_panel(), refresh=True) - - except KeyboardInterrupt: - console.print("\n[yellow]Selection cancelled[/yellow]") - raise typer.Exit(1) - - run_selection_loop() - - if selected_key is None: - console.print("\n[red]Selection failed.[/red]") - raise typer.Exit(1) - - return selected_key - -console = Console(highlight=False) - -class BannerGroup(TyperGroup): - """Custom group that shows banner before help.""" - - def format_help(self, ctx, formatter): - # Show banner before help - show_banner() - super().format_help(ctx, formatter) - - app = typer.Typer( name="specify", help="Setup tool for Specify spec-driven development projects", @@ -370,20 +172,6 @@ def format_help(self, ctx, formatter): cls=BannerGroup, ) -def show_banner(): - """Display the ASCII art banner.""" - banner_lines = BANNER.strip().split('\n') - colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"] - - styled_banner = Text() - for i, line in enumerate(banner_lines): - color = colors[i % len(colors)] - styled_banner.append(line + "\n", style=color) - - console.print(Align.center(styled_banner)) - console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) - console.print() - def _version_callback(value: bool): if value: console.print(f"specify {get_speckit_version()}") diff --git a/src/specify_cli/_console.py b/src/specify_cli/_console.py new file mode 100644 index 0000000000..85229e4c5c --- /dev/null +++ b/src/specify_cli/_console.py @@ -0,0 +1,245 @@ +"""Base Rich/Typer console layer for the specify CLI. + +This module is the single source of Rich ``Console`` instances and Typer UI +helpers used throughout ``specify_cli``. Nothing in this file should import +from other ``specify_cli`` sub-modules; all dependencies must flow *into* this +layer, not out of it, to avoid circular imports. +""" +from __future__ import annotations + +from collections.abc import Callable + +import readchar +import typer +from rich.align import Align +from rich.console import Console +from rich.live import Live +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich.tree import Tree +from typer.core import TyperGroup + +BANNER = """ +███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ +██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝ +███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝ +╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝ +███████║██║ ███████╗╚██████╗██║██║ ██║ +╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝ +""" + +TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit" + +console = Console(highlight=False) + +class StepTracker: + """Track and render hierarchical steps without emojis, similar to Claude Code tree output. + Supports live auto-refresh via an attached refresh callback. + """ + def __init__(self, title: str): + self.title = title + self.steps = [] # list of dicts: {key, label, status, detail} + self.status_order = {"pending": 0, "running": 1, "done": 2, "error": 3, "skipped": 4} + self._refresh_cb: Callable[[], None] | None = None + + def attach_refresh(self, cb: Callable[[], None]) -> None: + self._refresh_cb = cb + + def add(self, key: str, label: str): + if key not in [s["key"] for s in self.steps]: + self.steps.append({"key": key, "label": label, "status": "pending", "detail": ""}) + self._maybe_refresh() + + def start(self, key: str, detail: str = ""): + self._update(key, status="running", detail=detail) + + def complete(self, key: str, detail: str = ""): + self._update(key, status="done", detail=detail) + + def error(self, key: str, detail: str = ""): + self._update(key, status="error", detail=detail) + + def skip(self, key: str, detail: str = ""): + self._update(key, status="skipped", detail=detail) + + def _update(self, key: str, status: str, detail: str): + for s in self.steps: + if s["key"] == key: + s["status"] = status + if detail: + s["detail"] = detail + self._maybe_refresh() + return + + self.steps.append({"key": key, "label": key, "status": status, "detail": detail}) + self._maybe_refresh() + + def _maybe_refresh(self): + if self._refresh_cb: + try: + self._refresh_cb() + except Exception: + pass + + def render(self): + tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50") + for step in self.steps: + label = step["label"] + detail_text = step["detail"].strip() if step["detail"] else "" + + status = step["status"] + if status == "done": + symbol = "[green]●[/green]" + elif status == "pending": + symbol = "[green dim]○[/green dim]" + elif status == "running": + symbol = "[cyan]○[/cyan]" + elif status == "error": + symbol = "[red]●[/red]" + elif status == "skipped": + symbol = "[yellow]○[/yellow]" + else: + symbol = " " + + if status == "pending": + # Entire line light gray (pending) + if detail_text: + line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" + else: + line = f"{symbol} [bright_black]{label}[/bright_black]" + else: + # Label white, detail (if any) light gray in parentheses + if detail_text: + line = f"{symbol} [white]{label}[/white] [bright_black]({detail_text})[/bright_black]" + else: + line = f"{symbol} [white]{label}[/white]" + + tree.add(line) + return tree + + +def get_key(): + """Get a single keypress in a cross-platform way using readchar.""" + key = readchar.readkey() + + if key == readchar.key.UP or key == readchar.key.CTRL_P: + return 'up' + if key == readchar.key.DOWN or key == readchar.key.CTRL_N: + return 'down' + + if key == readchar.key.ENTER: + return 'enter' + + if key == readchar.key.ESC: + return 'escape' + + if key == readchar.key.CTRL_C: + raise KeyboardInterrupt + + return key + +def select_with_arrows( + options: dict[str, str], + prompt_text: str = "Select an option", + default_key: str | None = None, +) -> str: + """ + Interactive selection using arrow keys with Rich Live display. + + Args: + options: Dict with keys as option keys and values as descriptions + prompt_text: Text to show above the options + default_key: Default option key to start with + + Returns: + Selected option key + """ + if not options: + raise ValueError("select_with_arrows() requires at least one option.") + + option_keys = list(options.keys()) + if default_key and default_key in option_keys: + selected_index = option_keys.index(default_key) + else: + selected_index = 0 + + selected_key = None + + def create_selection_panel(): + """Create the selection panel with current selection highlighted.""" + table = Table.grid(padding=(0, 2)) + table.add_column(style="cyan", justify="left", width=3) + table.add_column(style="white", justify="left") + + for i, key in enumerate(option_keys): + if i == selected_index: + table.add_row("▶", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") + else: + table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") + + table.add_row("", "") + table.add_row("", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]") + + return Panel( + table, + title=f"[bold]{prompt_text}[/bold]", + border_style="cyan", + padding=(1, 2) + ) + + console.print() + + def run_selection_loop(): + nonlocal selected_key, selected_index + with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live: + while True: + try: + key = get_key() + if key == 'up': + selected_index = (selected_index - 1) % len(option_keys) + elif key == 'down': + selected_index = (selected_index + 1) % len(option_keys) + elif key == 'enter': + selected_key = option_keys[selected_index] + break + elif key == 'escape': + console.print("\n[yellow]Selection cancelled[/yellow]") + raise typer.Exit(code=1) + + live.update(create_selection_panel(), refresh=True) + + except KeyboardInterrupt: + console.print("\n[yellow]Selection cancelled[/yellow]") + raise typer.Exit(code=1) + + run_selection_loop() + + if selected_key is None: + console.print("\n[red]Selection failed.[/red]") + raise typer.Exit(code=1) + + return selected_key + +class BannerGroup(TyperGroup): + """Custom group that shows banner before help.""" + + def format_help(self, ctx, formatter): + # Show banner before help + show_banner() + super().format_help(ctx, formatter) + + +def show_banner(): + """Display the ASCII art banner.""" + banner_lines = BANNER.strip().split('\n') + colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"] + + styled_banner = Text() + for i, line in enumerate(banner_lines): + color = colors[i % len(colors)] + styled_banner.append(line + "\n", style=color) + + console.print(Align.center(styled_banner)) + console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) + console.print() diff --git a/tests/test_console_imports.py b/tests/test_console_imports.py new file mode 100644 index 0000000000..d0c93c5d74 --- /dev/null +++ b/tests/test_console_imports.py @@ -0,0 +1,29 @@ +"""Regression guard: console symbols must remain importable from specify_cli.""" +from specify_cli import ( + console, + StepTracker, + get_key, + select_with_arrows, + BannerGroup, + show_banner, + BANNER, + TAGLINE, +) + + +def test_console_symbols_importable(): + from rich.console import Console + assert isinstance(console, Console) + + +def test_step_tracker_instantiable(): + tracker = StepTracker("test") + tracker.add("step1", "Step One") + tracker.complete("step1", "done") + assert tracker.steps[0]["status"] == "done" + + +def test_select_with_arrows_raises_on_empty_options(): + import pytest + with pytest.raises(ValueError, match="at least one option"): + select_with_arrows({})