Initial checkin
This commit is contained in:
871
src/specify_cli/__init__.py
Normal file
871
src/specify_cli/__init__.py
Normal file
@@ -0,0 +1,871 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "typer",
|
||||
# "rich",
|
||||
# "platformdirs",
|
||||
# "readchar",
|
||||
# "httpx",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Specify CLI - Setup tool for Specify projects
|
||||
|
||||
Usage:
|
||||
uvx specify-cli.py init <project-name>
|
||||
uvx specify-cli.py init --here
|
||||
|
||||
Or install globally:
|
||||
uv tool install --from specify-cli.py specify-cli
|
||||
specify init <project-name>
|
||||
specify init --here
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
import httpx
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
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
|
||||
|
||||
# For cross-platform keyboard input
|
||||
import readchar
|
||||
|
||||
# Constants
|
||||
AI_CHOICES = {
|
||||
"copilot": "GitHub Copilot",
|
||||
"claude": "Claude Code",
|
||||
"gemini": "Gemini CLI"
|
||||
}
|
||||
|
||||
# ASCII Art Banner
|
||||
BANNER = """
|
||||
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
|
||||
██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
|
||||
███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝
|
||||
╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝
|
||||
███████║██║ ███████╗╚██████╗██║██║ ██║
|
||||
╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝
|
||||
"""
|
||||
|
||||
TAGLINE = "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
|
||||
# If not present, add it
|
||||
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"[bold cyan]{self.title}[/bold cyan]", guide_style="grey50")
|
||||
for step in self.steps:
|
||||
label = step["label"]
|
||||
detail_text = step["detail"].strip() if step["detail"] else ""
|
||||
|
||||
# Circles (unchanged styling)
|
||||
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
|
||||
|
||||
|
||||
|
||||
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:
|
||||
return 'up'
|
||||
if key == readchar.key.DOWN:
|
||||
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.
|
||||
|
||||
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="bright_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"[bright_cyan]{key}: {options[key]}[/bright_cyan]")
|
||||
else:
|
||||
table.add_row(" ", f"[white]{key}: {options[key]}[/white]")
|
||||
|
||||
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)
|
||||
|
||||
# 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()
|
||||
super().format_help(ctx, formatter)
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
name="specify",
|
||||
help="Setup tool for Specify spec-driven development projects",
|
||||
add_completion=False,
|
||||
invoke_without_command=True,
|
||||
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."""
|
||||
# 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]"))
|
||||
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:
|
||||
if capture:
|
||||
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
subprocess.run(cmd, check=check_return, shell=shell)
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
if check_return:
|
||||
console.print(f"[red]Error running command:[/red] {' '.join(cmd)}")
|
||||
console.print(f"[red]Exit code:[/red] {e.returncode}")
|
||||
if hasattr(e, 'stderr') and e.stderr:
|
||||
console.print(f"[red]Error output:[/red] {e.stderr}")
|
||||
raise
|
||||
return None
|
||||
|
||||
|
||||
def check_tool(tool: str, install_hint: str) -> bool:
|
||||
"""Check if a tool is installed."""
|
||||
if shutil.which(tool):
|
||||
return True
|
||||
else:
|
||||
console.print(f"[yellow]⚠️ {tool} not found[/yellow]")
|
||||
console.print(f" Install with: [cyan]{install_hint}[/cyan]")
|
||||
return False
|
||||
|
||||
|
||||
def is_git_repo(path: Path = None) -> bool:
|
||||
"""Check if the specified path is inside a git repository."""
|
||||
if path is None:
|
||||
path = Path.cwd()
|
||||
|
||||
if not path.is_dir():
|
||||
return False
|
||||
|
||||
try:
|
||||
# Use git command to check if inside a work tree
|
||||
subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
cwd=path,
|
||||
)
|
||||
return True
|
||||
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)
|
||||
"""
|
||||
try:
|
||||
original_cwd = Path.cwd()
|
||||
os.chdir(project_path)
|
||||
if not quiet:
|
||||
console.print("[cyan]Initializing git repository...[/cyan]")
|
||||
subprocess.run(["git", "init"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "add", "."], check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True)
|
||||
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}")
|
||||
return False
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
def download_template_from_github(ai_assistant: str, download_dir: Path, *, verbose: bool = True, show_progress: bool = True):
|
||||
"""Download the latest template release from GitHub using HTTP requests.
|
||||
Returns (zip_path, metadata_dict)
|
||||
"""
|
||||
repo_owner = "localden"
|
||||
repo_name = "sdd"
|
||||
|
||||
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 = httpx.get(api_url, timeout=30, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
release_data = response.json()
|
||||
except httpx.RequestError as e:
|
||||
if verbose:
|
||||
console.print(f"[red]Error fetching release information:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Find the template asset for the specified AI assistant
|
||||
pattern = f"spec-kit-template-{ai_assistant}"
|
||||
matching_assets = [
|
||||
asset for asset in release_data.get("assets", [])
|
||||
if pattern in asset["name"] and asset["name"].endswith(".zip")
|
||||
]
|
||||
|
||||
if not matching_assets:
|
||||
if verbose:
|
||||
console.print(f"[red]Error:[/red] No template found for AI assistant '{ai_assistant}'")
|
||||
console.print(f"[yellow]Available assets:[/yellow]")
|
||||
for asset in release_data.get("assets", []):
|
||||
console.print(f" - {asset['name']}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Use the first matching asset
|
||||
asset = matching_assets[0]
|
||||
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")
|
||||
console.print(f"[cyan]Release:[/cyan] {release_data['tag_name']}")
|
||||
|
||||
# Download the file
|
||||
zip_path = download_dir / filename
|
||||
if verbose:
|
||||
console.print(f"[cyan]Downloading template...[/cyan]")
|
||||
|
||||
try:
|
||||
with httpx.stream("GET", download_url, timeout=30, follow_redirects=True) as response:
|
||||
response.raise_for_status()
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
|
||||
with open(zip_path, 'wb') as f:
|
||||
if total_size == 0:
|
||||
# No content-length header, download without progress
|
||||
for chunk in response.iter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
else:
|
||||
if show_progress:
|
||||
# Show progress bar
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task("Downloading...", total=total_size)
|
||||
downloaded = 0
|
||||
for chunk in response.iter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
progress.update(task, completed=downloaded)
|
||||
else:
|
||||
# Silent download loop
|
||||
for chunk in response.iter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
if verbose:
|
||||
console.print(f"[red]Error downloading template:[/red] {e}")
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
raise typer.Exit(1)
|
||||
if verbose:
|
||||
console.print(f"Downloaded: {filename}")
|
||||
metadata = {
|
||||
"filename": filename,
|
||||
"size": file_size,
|
||||
"release": release_data["tag_name"],
|
||||
"asset_url": download_url
|
||||
}
|
||||
return zip_path, metadata
|
||||
|
||||
|
||||
def download_and_extract_template(project_path: Path, ai_assistant: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = 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")
|
||||
try:
|
||||
zip_path, meta = download_template_from_github(
|
||||
ai_assistant,
|
||||
current_dir,
|
||||
verbose=verbose and tracker is None,
|
||||
show_progress=(tracker is None)
|
||||
)
|
||||
if tracker:
|
||||
tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)")
|
||||
tracker.add("download", "Download template")
|
||||
tracker.complete("download", meta['filename']) # already downloaded inside helper
|
||||
except Exception as e:
|
||||
if tracker:
|
||||
tracker.error("fetch", str(e))
|
||||
else:
|
||||
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()
|
||||
if tracker:
|
||||
tracker.start("zip-list")
|
||||
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:
|
||||
tracker.start("extracted-summary")
|
||||
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():
|
||||
source_dir = extracted_items[0]
|
||||
if tracker:
|
||||
tracker.add("flatten", "Flatten nested directory")
|
||||
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
|
||||
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)
|
||||
dest_file = dest_path / rel_path
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
else:
|
||||
shutil.copytree(item, dest_path)
|
||||
else:
|
||||
if dest_path.exists() and verbose and not tracker:
|
||||
console.print(f"[yellow]Overwriting file:[/yellow] {item.name}")
|
||||
shutil.copy2(item, dest_path)
|
||||
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")
|
||||
tracker.complete("extracted-summary", f"{len(extracted_items)} top-level items")
|
||||
elif verbose:
|
||||
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
|
||||
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")
|
||||
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))
|
||||
else:
|
||||
if verbose:
|
||||
console.print(f"[red]Error extracting template:[/red] {e}")
|
||||
# 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)
|
||||
else:
|
||||
if tracker:
|
||||
tracker.complete("extract")
|
||||
finally:
|
||||
if tracker:
|
||||
tracker.add("cleanup", "Remove temporary archive")
|
||||
# Clean up downloaded ZIP file
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
if tracker:
|
||||
tracker.complete("cleanup")
|
||||
elif verbose:
|
||||
console.print(f"Cleaned up: {zip_path.name}")
|
||||
|
||||
return project_path
|
||||
|
||||
|
||||
@app.command()
|
||||
def init(
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here)"),
|
||||
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, or copilot"),
|
||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"),
|
||||
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
||||
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project from the latest template.
|
||||
|
||||
This command will:
|
||||
1. Check that required tools are installed (git is optional)
|
||||
2. Let you choose your AI assistant (Claude Code, Gemini CLI, or GitHub Copilot)
|
||||
3. Download the appropriate template from GitHub
|
||||
4. Extract the template to a new project directory or current directory
|
||||
5. Initialize a fresh git repository (if not --no-git and no existing repo)
|
||||
6. Optionally set up AI assistant commands
|
||||
|
||||
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 --ignore-agent-tools my-project
|
||||
specify init --here --ai claude
|
||||
specify init --here
|
||||
"""
|
||||
# Show banner first
|
||||
show_banner()
|
||||
|
||||
# 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 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)")
|
||||
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
|
||||
|
||||
# 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():
|
||||
console.print(f"[red]Error:[/red] Directory '{project_name}' already exists")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(Panel.fit(
|
||||
"[bold cyan]Specify Project Setup[/bold cyan]\n"
|
||||
f"{'Initializing in current directory:' if here else 'Creating new project:'} [green]{project_path.name}[/green]"
|
||||
+ (f"\n[dim]Path: {project_path}[/dim]" if here else ""),
|
||||
border_style="cyan"
|
||||
))
|
||||
|
||||
# Check git only if we might need it (not --no-git)
|
||||
git_available = True
|
||||
if not no_git:
|
||||
git_available = check_tool("git", "https://git-scm.com/downloads")
|
||||
if not git_available:
|
||||
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())}")
|
||||
raise typer.Exit(1)
|
||||
selected_ai = ai_assistant
|
||||
else:
|
||||
# Use arrow-key selection interface
|
||||
selected_ai = select_with_arrows(
|
||||
AI_CHOICES,
|
||||
"Choose your AI assistant:",
|
||||
"copilot"
|
||||
)
|
||||
|
||||
# Check agent tools unless ignored
|
||||
if not ignore_agent_tools:
|
||||
agent_tool_missing = False
|
||||
if selected_ai == "claude":
|
||||
if not check_tool("claude", "Install from: https://docs.anthropic.com/en/docs/claude-code/setup"):
|
||||
console.print("[red]Error:[/red] Claude CLI is required for Claude Code projects")
|
||||
agent_tool_missing = True
|
||||
elif selected_ai == "gemini":
|
||||
if not check_tool("gemini", "Install from: https://github.com/google-gemini/gemini-cli"):
|
||||
console.print("[red]Error:[/red] Gemini CLI is required for Gemini projects")
|
||||
agent_tool_missing = True
|
||||
# GitHub Copilot check is not needed as it's typically available in supported IDEs
|
||||
|
||||
if agent_tool_missing:
|
||||
console.print("\n[red]Required AI tool is missing![/red]")
|
||||
console.print("[yellow]Tip:[/yellow] Use --ignore-agent-tools to skip this check")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# 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")
|
||||
tracker.complete("ai-select", f"{selected_ai}")
|
||||
for key, label in [
|
||||
("fetch", "Fetch latest release"),
|
||||
("download", "Download template"),
|
||||
("extract", "Extract template"),
|
||||
("zip-list", "Archive contents"),
|
||||
("extracted-summary", "Extraction summary"),
|
||||
("cleanup", "Cleanup"),
|
||||
("git", "Initialize git repository"),
|
||||
("final", "Finalize")
|
||||
]:
|
||||
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:
|
||||
download_and_extract_template(project_path, selected_ai, here, verbose=False, tracker=tracker)
|
||||
|
||||
# Git step
|
||||
if not no_git:
|
||||
tracker.start("git")
|
||||
if is_git_repo(project_path):
|
||||
tracker.complete("git", "existing repo detected")
|
||||
elif git_available:
|
||||
if init_git_repo(project_path, quiet=True):
|
||||
tracker.complete("git", "initialized")
|
||||
else:
|
||||
tracker.error("git", "init failed")
|
||||
else:
|
||||
tracker.skip("git", "git not available")
|
||||
else:
|
||||
tracker.skip("git", "--no-git flag")
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except Exception as e:
|
||||
tracker.error("final", str(e))
|
||||
if not here and project_path.exists():
|
||||
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]")
|
||||
|
||||
# Boxed "Next steps" section
|
||||
steps_lines = []
|
||||
if not here:
|
||||
steps_lines.append(f"1. [bold green]cd {project_name}[/bold green]")
|
||||
step_num = 2
|
||||
else:
|
||||
steps_lines.append("1. You're already in the project directory!")
|
||||
step_num = 2
|
||||
|
||||
if selected_ai == "claude":
|
||||
steps_lines.append(f"{step_num}. Open in Visual Studio Code and start using / commands with Claude Code")
|
||||
steps_lines.append(" - Type / in any file to see available commands")
|
||||
steps_lines.append(" - Use /spec to create specifications")
|
||||
steps_lines.append(" - Use /plan to create implementation plans")
|
||||
steps_lines.append(" - Use /tasks to generate tasks")
|
||||
elif selected_ai == "gemini":
|
||||
steps_lines.append(f"{step_num}. Use / commands with Gemini CLI")
|
||||
steps_lines.append(" - Run gemini /spec to create specifications")
|
||||
steps_lines.append(" - Run gemini /plan to create implementation plans")
|
||||
steps_lines.append(" - See GEMINI.md for all available commands")
|
||||
elif selected_ai == "copilot":
|
||||
steps_lines.append(f"{step_num}. Open in Visual Studio Code and use [bold cyan]/specify[/], [bold cyan]/plan[/], [bold cyan]/tasks[/] commands with GitHub Copilot")
|
||||
|
||||
step_num += 1
|
||||
steps_lines.append(f"{step_num}. Update [bold magenta]CONSTITUTION.md[/bold magenta] with your project's non-negotiable principles")
|
||||
|
||||
steps_panel = Panel("\n".join(steps_lines), title="Next steps", border_style="cyan", padding=(1,2))
|
||||
console.print() # blank line
|
||||
console.print(steps_panel)
|
||||
|
||||
# Removed farewell line per user request
|
||||
|
||||
|
||||
@app.command()
|
||||
def check():
|
||||
"""Check that all required tools are installed."""
|
||||
show_banner()
|
||||
console.print("[bold]Checking Specify requirements...[/bold]\n")
|
||||
|
||||
# Check if we have internet connectivity by trying to reach GitHub API
|
||||
console.print("[cyan]Checking internet connectivity...[/cyan]")
|
||||
try:
|
||||
response = httpx.get("https://api.github.com", timeout=5, follow_redirects=True)
|
||||
console.print("[green]✓[/green] Internet connection available")
|
||||
except httpx.RequestError:
|
||||
console.print("[red]✗[/red] No internet connection - required for downloading templates")
|
||||
console.print("[yellow]Please check your internet connection[/yellow]")
|
||||
|
||||
console.print("\n[cyan]Optional tools:[/cyan]")
|
||||
git_ok = check_tool("git", "https://git-scm.com/downloads")
|
||||
|
||||
console.print("\n[cyan]Optional AI tools:[/cyan]")
|
||||
claude_ok = check_tool("claude", "Install from: https://docs.anthropic.com/en/docs/claude-code/setup")
|
||||
gemini_ok = check_tool("gemini", "Install from: https://github.com/google-gemini/gemini-cli")
|
||||
|
||||
console.print("\n[green]✓ Specify CLI is ready to use![/green]")
|
||||
if not git_ok:
|
||||
console.print("[yellow]Consider installing git for repository management[/yellow]")
|
||||
if not (claude_ok or gemini_ok):
|
||||
console.print("[yellow]Consider installing an AI assistant for the best experience[/yellow]")
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user