From 9c87fdd5bbd2dd5231214674faf304058d855237 Mon Sep 17 00:00:00 2001 From: "den (work)" <53200638+localden@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:22:57 -0700 Subject: [PATCH] Update __init__.py --- src/specify_cli/__init__.py | 74 ++++++++++--------------------------- 1 file changed, 19 insertions(+), 55 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1da5466f..5d53109d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -146,10 +146,6 @@ AGENT_CONFIG = { }, } -# Derived dictionaries for backward compatibility -AI_CHOICES = {key: config["name"] for key, config in AGENT_CONFIG.items()} -AGENT_FOLDER_MAP = {key: config["folder"] for key, config in AGENT_CONFIG.items()} - SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" @@ -202,7 +198,7 @@ class StepTracker: s["detail"] = detail self._maybe_refresh() return - # If not present, add it + self.steps.append({"key": key, "label": key, "status": status, "detail": detail}) self._maybe_refresh() @@ -219,7 +215,6 @@ class StepTracker: label = step["label"] detail_text = step["detail"].strip() if step["detail"] else "" - # Circles (unchanged styling) status = step["status"] if status == "done": symbol = "[green]●[/green]" @@ -343,7 +338,6 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def console.print("\n[red]Selection failed.[/red]") raise typer.Exit(1) - # Suppress explicit selection print; tracker / later logic will report consolidated status return selected_key console = Console() @@ -367,7 +361,6 @@ app = typer.Typer( 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"] @@ -383,8 +376,6 @@ def show_banner(): @app.callback() def callback(ctx: typer.Context): """Show banner when no subcommand is provided.""" - # Show banner only when no subcommand and no help flag - # (help is handled by BannerGroup) if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv: show_banner() console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]")) @@ -515,7 +506,6 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri 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}" matching_assets = [ @@ -600,7 +590,6 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ """ current_dir = Path.cwd() - # Step: fetch + download combined if tracker: tracker.start("fetch", "contacting GitHub API") try: @@ -633,12 +622,10 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ 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() if tracker: tracker.start("zip-list") @@ -646,13 +633,11 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ 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: tracker.start("extracted-summary") @@ -660,7 +645,6 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ 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(): source_dir = extracted_items[0] @@ -670,14 +654,12 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ 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 if item.is_dir(): if dest_path.exists(): if verbose and not tracker: console.print(f"[yellow]Merging directory:[/yellow] {item.name}") - # Recursively copy directory contents for sub_item in item.rglob('*'): if sub_item.is_file(): rel_path = sub_item.relative_to(item) @@ -693,10 +675,8 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ if verbose and not tracker: console.print(f"[cyan]Template files merged into current directory[/cyan]") 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: tracker.start("extracted-summary") @@ -706,16 +686,14 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ 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 nested_dir = extracted_items[0] temp_move_dir = project_path.parent / f"{project_path.name}_temp" - # Move the nested directory contents to temp location + shutil.move(str(nested_dir), str(temp_move_dir)) - # Remove the now-empty project directory + project_path.rmdir() - # Rename temp directory to project directory + shutil.move(str(temp_move_dir), str(project_path)) if tracker: tracker.add("flatten", "Flatten nested directory") @@ -731,7 +709,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ console.print(f"[red]Error extracting template:[/red] {e}") if debug: console.print(Panel(str(e), title="Extraction Error", border_style="red")) - # Clean up project directory if created and not current directory + if not is_current_dir and project_path.exists(): shutil.rmtree(project_path) raise typer.Exit(1) @@ -741,7 +719,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ finally: if tracker: tracker.add("cleanup", "Remove temporary archive") - # Clean up downloaded ZIP file + if zip_path.exists(): zip_path.unlink() if tracker: @@ -836,7 +814,6 @@ def init( 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 @@ -887,14 +864,11 @@ def init( 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 if not no_git: should_init_git = check_tool("git", "https://git-scm.com/downloads") @@ -902,32 +876,32 @@ def init( console.print("[yellow]Git not found - will skip repository initialization[/yellow]") 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())}") + if ai_assistant not in AGENT_CONFIG: + console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") raise typer.Exit(1) selected_ai = ai_assistant else: - # Use arrow-key selection interface + # Create options dict for selection (agent_key: display_name) + ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} selected_ai = select_with_arrows( - AI_CHOICES, + ai_choices, "Choose your AI assistant:", "copilot" ) - # Check agent tools unless ignored if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) if agent_config and agent_config["requires_cli"]: cli_tool = selected_ai if selected_ai == "cursor": cli_tool = "cursor-agent" - + install_url = agent_config["install_url"] if not check_tool(cli_tool, install_url): error_panel = Panel( f"[cyan]{selected_ai}[/cyan] not found\n" f"Install from: [cyan]{install_url}[/cyan]\n" - f"{AI_CHOICES[selected_ai]} is required to continue with this project type.\n\n" + f"{agent_config['name']} is required to continue with this project type.\n\n" "Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check", title="[red]Agent Detection Error[/red]", border_style="red", @@ -937,16 +911,14 @@ def init( 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: console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}") raise typer.Exit(1) selected_script = script_type else: - # Auto-detect default default_script = "ps" if os.name == "nt" else "sh" - # Provide interactive selection similar to AI if stdin is a TTY + if sys.stdin.isatty(): selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script) else: @@ -955,12 +927,10 @@ def init( 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") - # Flag to allow suppressing legacy headings + sys._specify_tracker_active = True - # Pre steps recorded as completed before live rendering + tracker.add("precheck", "Check required tools") tracker.complete("precheck", "ok") tracker.add("ai-select", "Select AI assistant") @@ -980,21 +950,17 @@ def init( ]: tracker.add(key, label) - # Use transient so live tree is replaced by the final static render (avoids duplicate output) with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - # Create a httpx client with verify based on skip_tls verify = not skip_tls local_ssl_context = ssl_context if verify else False local_client = httpx.Client(verify=local_ssl_context) download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) - # Ensure scripts are executable (POSIX) ensure_executable_scripts(project_path, tracker=tracker) - # Git step if not no_git: tracker.start("git") if is_git_repo(project_path): @@ -1026,16 +992,15 @@ def init( shutil.rmtree(project_path) raise typer.Exit(1) finally: - # Force final render pass - # 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 - if selected_ai in AGENT_FOLDER_MAP: - agent_folder = AGENT_FOLDER_MAP[selected_ai] + agent_config = AGENT_CONFIG.get(selected_ai) + if agent_config: + agent_folder = agent_config["folder"] security_notice = Panel( f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", @@ -1046,7 +1011,6 @@ def init( console.print() console.print(security_notice) - # Boxed "Next steps" section steps_lines = [] if not here: steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")