From 14ebde575f780f6fc36f109b13142ec9d7f184ab Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:26:07 -0700 Subject: [PATCH] Cleanup --- src/specify_cli/__init__.py | 150 +++++++++++++----------------------- 1 file changed, 52 insertions(+), 98 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index eeeb4917..58977938 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -64,7 +64,6 @@ def _github_auth_headers(cli_token: str | None = None) -> dict: token = _github_token(cli_token) return {"Authorization": f"Bearer {token}"} if token else {} -# Constants AI_CHOICES = { "copilot": "GitHub Copilot", "claude": "Claude Code", @@ -79,13 +78,11 @@ AI_CHOICES = { "roo": "Roo Code", "q": "Amazon Q Developer CLI", } -# Add script type choices + SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} -# Claude CLI local installation path after migrate-installer CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" -# ASCII Art Banner BANNER = """ ███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ ██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝ @@ -182,40 +179,26 @@ class StepTracker: tree.add(line) return tree - - -MINI_BANNER = """ -╔═╗╔═╗╔═╗╔═╗╦╔═╗╦ ╦ -╚═╗╠═╝║╣ ║ ║╠╣ ╚╦╝ -╚═╝╩ ╚═╝╚═╝╩╚ ╩ -""" - def get_key(): """Get a single keypress in a cross-platform way using readchar.""" key = readchar.readkey() - - # Arrow keys + 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' - - # Enter/Return + if key == readchar.key.ENTER: return 'enter' - - # Escape + if key == readchar.key.ESC: return 'escape' - - # Ctrl+C + 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. @@ -233,7 +216,7 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def selected_index = option_keys.index(default_key) else: selected_index = 0 - + selected_key = None def create_selection_panel(): @@ -241,23 +224,23 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def 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(): @@ -276,7 +259,7 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def elif key == 'escape': console.print("\n[yellow]Selection cancelled[/yellow]") raise typer.Exit(1) - + live.update(create_selection_panel(), refresh=True) except KeyboardInterrupt: @@ -292,14 +275,11 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def # Suppress explicit selection print; tracker / later logic will report consolidated status return selected_key - - console = Console() - class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" - + def format_help(self, ctx, formatter): # Show banner before help show_banner() @@ -314,23 +294,21 @@ app = typer.Typer( cls=BannerGroup, ) - def show_banner(): """Display the ASCII art banner.""" # Create gradient effect with different colors 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() - @app.callback() def callback(ctx: typer.Context): """Show banner when no subcommand is provided.""" @@ -341,7 +319,6 @@ def callback(ctx: typer.Context): console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]")) console.print() - def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]: """Run a shell command and optionally capture output.""" try: @@ -360,7 +337,6 @@ def run_command(cmd: list[str], check_return: bool = True, capture: bool = False raise return None - def check_tool_for_tracker(tool: str, tracker: StepTracker) -> bool: """Check if a tool is installed and update tracker.""" if shutil.which(tool): @@ -370,7 +346,6 @@ def check_tool_for_tracker(tool: str, tracker: StepTracker) -> bool: tracker.error(tool, "not found") return False - def check_tool(tool: str, install_hint: str) -> bool: """Check if a tool is installed.""" @@ -388,7 +363,6 @@ def check_tool(tool: str, install_hint: str) -> bool: else: return False - def is_git_repo(path: Path = None) -> bool: """Check if the specified path is inside a git repository.""" if path is None: @@ -409,7 +383,6 @@ def is_git_repo(path: Path = None) -> bool: except (subprocess.CalledProcessError, FileNotFoundError): return False - def init_git_repo(project_path: Path, quiet: bool = False) -> bool: """Initialize a git repository in the specified path. quiet: if True suppress console output (tracker handles status) @@ -425,7 +398,7 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> bool: if not quiet: console.print("[green]✓[/green] Git repository initialized") return True - + except subprocess.CalledProcessError as e: if not quiet: console.print(f"[red]Error initializing git repository:[/red] {e}") @@ -433,17 +406,16 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> bool: finally: os.chdir(original_cwd) - def download_template_from_github(ai_assistant: str, download_dir: Path, *, script_type: str = "sh", verbose: bool = True, show_progress: bool = True, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Tuple[Path, dict]: repo_owner = "github" repo_name = "spec-kit" if client is None: client = httpx.Client(verify=ssl_context) - + if verbose: console.print("[cyan]Fetching latest release information...[/cyan]") api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" - + try: response = client.get( api_url, @@ -465,7 +437,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri console.print(f"[red]Error fetching release information[/red]") console.print(Panel(str(e), title="Fetch Error", border_style="red")) raise typer.Exit(1) - + # Find the template asset for the specified AI assistant assets = release_data.get("assets", []) pattern = f"spec-kit-template-{ai_assistant}-{script_type}" @@ -485,7 +457,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri download_url = asset["browser_download_url"] filename = asset["name"] file_size = asset["size"] - + if verbose: console.print(f"[cyan]Found template:[/cyan] {filename}") console.print(f"[cyan]Size:[/cyan] {file_size:,} bytes") @@ -494,7 +466,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri zip_path = download_dir / filename if verbose: console.print(f"[cyan]Downloading template...[/cyan]") - + try: with client.stream( "GET", @@ -545,13 +517,12 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri } return zip_path, metadata - def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path: """Download the latest release and extract it to create a new project. Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup) """ current_dir = Path.cwd() - + # Step: fetch + download combined if tracker: tracker.start("fetch", "contacting GitHub API") @@ -577,18 +548,18 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ if verbose: console.print(f"[red]Error downloading template:[/red] {e}") raise - + if tracker: tracker.add("extract", "Extract template") tracker.start("extract") elif verbose: console.print("Extracting template...") - + try: # Create project directory only if not using current directory if not is_current_dir: project_path.mkdir(parents=True) - + with zipfile.ZipFile(zip_path, 'r') as zip_ref: # List all files in the ZIP for debugging zip_contents = zip_ref.namelist() @@ -597,13 +568,13 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.complete("zip-list", f"{len(zip_contents)} entries") elif verbose: console.print(f"[cyan]ZIP contains {len(zip_contents)} items[/cyan]") - + # For current directory, extract to a temp location first if is_current_dir: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) zip_ref.extractall(temp_path) - + # Check what was extracted extracted_items = list(temp_path.iterdir()) if tracker: @@ -611,7 +582,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.complete("extracted-summary", f"temp {len(extracted_items)} items") elif verbose: console.print(f"[cyan]Extracted {len(extracted_items)} items to temp location[/cyan]") - + # Handle GitHub-style ZIP with a single root directory source_dir = temp_path if len(extracted_items) == 1 and extracted_items[0].is_dir(): @@ -621,7 +592,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.complete("flatten") elif verbose: console.print(f"[cyan]Found nested directory structure[/cyan]") - + # Copy contents to current directory for item in source_dir.iterdir(): dest_path = project_path / item.name @@ -647,7 +618,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ else: # Extract directly to project directory (original behavior) zip_ref.extractall(project_path) - + # Check what was extracted extracted_items = list(project_path.iterdir()) if tracker: @@ -657,7 +628,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ console.print(f"[cyan]Extracted {len(extracted_items)} items to {project_path}:[/cyan]") for item in extracted_items: console.print(f" - {item.name} ({'dir' if item.is_dir() else 'file'})") - + # Handle GitHub-style ZIP with a single root directory if len(extracted_items) == 1 and extracted_items[0].is_dir(): # Move contents up one level @@ -674,7 +645,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.complete("flatten") elif verbose: console.print(f"[cyan]Flattened nested directory structure[/cyan]") - + except Exception as e: if tracker: tracker.error("extract", str(e)) @@ -700,7 +671,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.complete("cleanup") elif verbose: console.print(f"Cleaned up: {zip_path.name}") - + return project_path @@ -775,15 +746,7 @@ def init( Examples: specify init my-project specify init my-project --ai claude - specify init my-project --ai gemini specify init my-project --ai copilot --no-git - specify init my-project --ai cursor - specify init my-project --ai qwen - specify init my-project --ai opencode - specify init my-project --ai codex - specify init my-project --ai windsurf - specify init my-project --ai auggie - specify init my-project --ai q specify init --ignore-agent-tools my-project specify init . --ai claude # Initialize in current directory specify init . # Initialize in current directory (interactive AI selection) @@ -792,29 +755,26 @@ def init( specify init --here specify init --here --force # Skip confirmation when current directory not empty """ - # Show banner first + show_banner() - + # Handle '.' as shorthand for current directory (equivalent to --here) if project_name == ".": here = True project_name = None # Clear project_name to use existing validation logic - - # Validate arguments + if here and project_name: console.print("[red]Error:[/red] Cannot specify both project name and --here flag") raise typer.Exit(1) - + if not here and not project_name: console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") raise typer.Exit(1) - - # Determine project directory + if here: project_name = Path.cwd().name project_path = Path.cwd() - - # Check if current directory has any files + existing_items = list(project_path.iterdir()) if existing_items: console.print(f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)") @@ -822,14 +782,12 @@ def init( if force: console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]") else: - # Ask for confirmation response = typer.confirm("Do you want to continue?") if not response: console.print("[yellow]Operation cancelled[/yellow]") raise typer.Exit(0) else: project_path = Path(project_name).resolve() - # Check if project directory already exists if project_path.exists(): error_panel = Panel( f"Directory '[cyan]{project_name}[/cyan]' already exists\n" @@ -841,23 +799,22 @@ def init( console.print() console.print(error_panel) raise typer.Exit(1) - - # Create formatted setup info with column alignment + current_dir = Path.cwd() - + setup_lines = [ "[cyan]Specify Project Setup[/cyan]", "", f"{'Project':<15} [green]{project_path.name}[/green]", f"{'Working Path':<15} [dim]{current_dir}[/dim]", ] - + # Add target path only if different from working dir if not here: setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]") - + console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))) - + # Check git only if we might need it (not --no-git) # Only set to True if the user wants it and the tool is available should_init_git = False @@ -866,7 +823,6 @@ def init( if not should_init_git: console.print("[yellow]Git not found - will skip repository initialization[/yellow]") - # AI assistant selection if ai_assistant: if ai_assistant not in AI_CHOICES: console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AI_CHOICES.keys())}") @@ -879,7 +835,7 @@ def init( "Choose your AI assistant:", "copilot" ) - + # Check agent tools unless ignored if not ignore_agent_tools: agent_tool_missing = False @@ -927,7 +883,7 @@ def init( console.print() console.print(error_panel) raise typer.Exit(1) - + # Determine script type (explicit, interactive, or OS default) if script_type: if script_type not in SCRIPT_TYPE_CHOICES: @@ -942,10 +898,10 @@ def init( selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script) else: selected_script = default_script - + console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") - + # Download and set up project # New tree-based progress (no emojis); include earlier substeps tracker = StepTracker("Initialize Specify Project") @@ -1023,7 +979,7 @@ def init( # Final static tree (ensures finished state visible after Live context ends) console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") - + # Agent folder security notice agent_folder_map = { "claude": ".claude/", @@ -1039,7 +995,7 @@ def init( "roo": ".roo/", "q": ".amazonq/" } - + if selected_ai in agent_folder_map: agent_folder = agent_folder_map[selected_ai] security_notice = Panel( @@ -1051,7 +1007,7 @@ def init( ) console.print() console.print(security_notice) - + # Boxed "Next steps" section steps_lines = [] if not here: @@ -1103,7 +1059,7 @@ def check(): console.print("[bold]Checking for installed tools...[/bold]\n") tracker = StepTracker("Check Available Tools") - + tracker.add("git", "Git version control") tracker.add("claude", "Claude Code CLI") tracker.add("gemini", "Gemini CLI") @@ -1117,7 +1073,7 @@ def check(): tracker.add("codex", "Codex CLI") tracker.add("auggie", "Auggie CLI") tracker.add("q", "Amazon Q Developer CLI") - + git_ok = check_tool_for_tracker("git", tracker) claude_ok = check_tool_for_tracker("claude", tracker) gemini_ok = check_tool_for_tracker("gemini", tracker) @@ -1141,10 +1097,8 @@ def check(): if not (claude_ok or gemini_ok or cursor_ok or qwen_ok or windsurf_ok or kilocode_ok or opencode_ok or codex_ok or auggie_ok or q_ok): console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]") - def main(): app() - if __name__ == "__main__": main()