refactor: extract _console.py from __init__.py (PR-1/8) (#2474)

* refactor: extract _console.py from __init__.py

Move Rich UI primitives (BANNER, TAGLINE, StepTracker, get_key,
select_with_arrows, console, BannerGroup, show_banner) into a new
src/specify_cli/_console.py module. Re-export all symbols from
__init__.py to preserve the public API. Add regression guard tests.

* refactor(console): improve type annotations and add guard for empty options

- Add module-level docstring documenting the console layer's purpose and
  the dependency-layering rule (no imports from other specify_cli modules)
- Tighten select_with_arrows() signature: options typed as dict[str, str]
  and default_key as str | None to align with repo typing style
- Add early ValueError guard when options is empty, preventing downstream
  ZeroDivisionError / IndexError inside the Live loop

* refactor(console): improve type safety and code quality in _console.py

- Add Callable import from collections.abc for precise callback typing
- Annotate StepTracker._refresh_cb as Callable[[], None] | None
- Add parameter/return types to attach_refresh()
- Use explicit keyword form typer.Exit(code=1) across all error exits
- Add blank line between StepTracker class and get_key() (PEP 8)
- Add regression test for select_with_arrows() raising ValueError on
  empty options dict

* style(cli): add __all__ declaration to fix Ruff F401 lint warnings

- Add explicit __all__ for intentional re-exports (BANNER, TAGLINE, get_key)
- Prevent F401 unused import errors in CI lint checks
- Maintain backward compatibility for external imports

* Preserve public console imports

The CLI package intentionally re-exports console helpers for compatibility, so __all__ must track that public surface instead of narrowing star imports to a partial set.

Constraint: Existing tests import console helpers directly from specify_cli

Rejected: Remove __all__ entirely | keeping an explicit export list documents the intended compatibility surface

Confidence: high

Scope-risk: narrow

Directive: Keep __all__ synchronized when adding or removing specify_cli public re-exports

Tested: uv run pytest tests/test_console_imports.py -q

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* style(cli): use explicit re-export syntax to fix ruff F401 warnings

Use `X as X` form for BANNER, TAGLINE, and get_key imports
to mark them as intentional public re-exports and silence
ruff F401 lint errors.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
darion-yaphet
2026-05-13 21:57:15 +08:00
committed by GitHub
parent 0aae1ec2b9
commit 4f51e066c3
3 changed files with 301 additions and 222 deletions

View File

@@ -45,15 +45,10 @@ from packaging.version import InvalidVersion, Version
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 @@ from .shared_infra import (
refresh_shared_templates as _refresh_shared_templates_impl,
)
# For cross-platform keyboard input
import readchar
from ._console import (
BANNER as BANNER,
TAGLINE as TAGLINE,
BannerGroup,
StepTracker,
console,
get_key as get_key,
select_with_arrows,
show_banner,
)
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
@@ -161,207 +164,6 @@ SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
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 @@ app = typer.Typer(
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()}")

245
src/specify_cli/_console.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1,46 @@
"""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_console_symbols_available_from_star_import():
namespace = {}
exec("from specify_cli import *", namespace)
for symbol in (
"console",
"StepTracker",
"get_key",
"select_with_arrows",
"BannerGroup",
"show_banner",
"BANNER",
"TAGLINE",
):
assert symbol in namespace
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({})