This commit is contained in:
den (work)
2025-10-06 21:26:07 -07:00
parent 337e192abd
commit 14ebde575f

View File

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