Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 10 additions & 222 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"

Expand Down Expand Up @@ -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",
Expand All @@ -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()}")
Expand Down
Loading
Loading