Files
spec-kit/src/specify_cli/__init__.py

1257 lines
52 KiB
Python

#!/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, Tuple
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
import ssl
import truststore
ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
client = httpx.Client(verify=ssl_context)
# Constants
AI_CHOICES = {
"copilot": "GitHub Copilot",
"claude": "Claude Code",
"gemini": "Gemini CLI",
"cursor": "Cursor",
"qwen": "Qwen Code",
"opencode": "opencode",
"codex": "Codex 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"
# Embedded fallback command templates for Codex (used only if packaged templates are unavailable)
CODEX_CMD_SPECIFY = """---
description: Create or update the feature specification from a natural language feature description.
scripts:
sh: scripts/bash/create-new-feature.sh --json "{ARGS}"
ps: scripts/powershell/create-new-feature.ps1 -Json "{ARGS}"
---
Given the feature description provided as an argument, do this:
1. Run the script `{SCRIPT}` from repo root and parse its JSON output for BRANCH_NAME and SPEC_FILE. All file paths must be absolute.
2. Load `templates/spec-template.md` to understand required sections.
3. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
4. Report completion with branch name, spec file path, and readiness for the next phase.
Note: The script creates and checks out the new branch and initializes the spec file before writing.
"""
CODEX_CMD_PLAN = """---
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
scripts:
sh: scripts/bash/setup-plan.sh --json
ps: scripts/powershell/setup-plan.ps1 -Json
---
Given the implementation details provided as an argument, do this:
1. Run `{SCRIPT}` from the repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. All future file paths must be absolute.
2. Read and analyze the feature specification to understand:
- The feature requirements and user stories
- Functional and non-functional requirements
- Success criteria and acceptance criteria
- Any technical constraints or dependencies mentioned
3. Read the constitution at `memory/constitution.md` to understand constitutional requirements.
4. Execute the implementation plan template:
- Load `templates/plan-template.md` (already copied to IMPL_PLAN path)
- Set Input path to FEATURE_SPEC
- Run the Execution Flow (main) function steps 1-9
- The template is self-contained and executable
- Follow error handling and gate checks as specified
- Let the template guide artifact generation in $SPECS_DIR:
* Phase 0 generates research.md
* Phase 1 generates data-model.md, contracts/, quickstart.md
* Phase 2 generates tasks.md
- Incorporate user-provided details from arguments into Technical Context: {ARGS}
- Update Progress Tracking as you complete each phase
5. Verify execution completed:
- Check Progress Tracking shows all phases complete
- Ensure all required artifacts were generated
- Confirm no ERROR states in execution
6. Report results with branch name, file paths, and generated artifacts.
Use absolute paths with the repository root for all file operations to avoid path issues.
"""
CODEX_CMD_TASKS = """---
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
scripts:
sh: scripts/bash/check-task-prerequisites.sh --json
ps: scripts/powershell/check-task-prerequisites.ps1 -Json
---
Given the context provided as an argument, do this:
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute.
2. Load and analyze available design documents:
- Always read plan.md for tech stack and libraries
- IF EXISTS: Read data-model.md for entities
- IF EXISTS: Read contracts/ for API endpoints
- IF EXISTS: Read research.md for technical decisions
- IF EXISTS: Read quickstart.md for test scenarios
Note: Not all projects have all documents. For example:
- CLI tools might not have contracts/
- Simple libraries might not need data-model.md
- Generate tasks based on what's available
3. Generate tasks following the template:
- Use `templates/tasks-template.md` as the base
- Replace example tasks with actual tasks based on:
* **Setup tasks**: Project init, dependencies, linting
* **Test tasks [P]**: One per contract, one per integration scenario
* **Core tasks**: One per entity, service, CLI command, endpoint
* **Integration tasks**: DB connections, middleware, logging
* **Polish tasks [P]**: Unit tests, performance, docs
4. Task generation rules:
- Each contract file → contract test task marked [P]
- Each entity in data-model → model creation task marked [P]
- Each endpoint → implementation task (not parallel if shared files)
- Each user story → integration test marked [P]
- Different files = can be parallel [P]
- Same file = sequential (no [P])
5. Order tasks by dependencies:
- Setup before everything
- Tests before implementation (TDD)
- Models before services
- Services before endpoints
- Core before integration
- Everything before polish
6. Include parallel execution examples:
- Group [P] tasks that can run together
- Show actual Task agent commands
7. Create FEATURE_DIR/tasks.md with:
- Correct feature name from implementation plan
- Numbered tasks (T001, T002, etc.)
- Clear file paths for each task
- Dependency notes
- Parallel execution guidance
Context for task generation: {ARGS}
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
"""
# Utility to ensure Codex command templates use the modern schema (with scripts mapping)
def ensure_codex_command_templates_current(commands_dir: Path) -> None:
expected = {
"specify.md": CODEX_CMD_SPECIFY,
"plan.md": CODEX_CMD_PLAN,
"tasks.md": CODEX_CMD_TASKS,
}
def needs_upgrade(content: str) -> bool:
# Old templates lacked the scripts: mapping and {SCRIPT} placeholder
return "scripts:" not in content or "{SCRIPT}" not in content
for filename, template_text in expected.items():
target_file = commands_dir / filename
if target_file.exists():
try:
current = target_file.read_text(encoding="utf-8")
except Exception:
target_file.write_text(template_text, encoding="utf-8")
else:
if needs_upgrade(current):
target_file.write_text(template_text, encoding="utf-8")
else:
target_file.parent.mkdir(parents=True, exist_ok=True)
target_file.write_text(template_text, encoding="utf-8")
# 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_for_tracker(tool: str, install_hint: str, tracker: StepTracker) -> bool:
"""Check if a tool is installed and update tracker."""
if shutil.which(tool):
tracker.complete(tool, "available")
return True
else:
tracker.error(tool, f"not found - {install_hint}")
return False
def check_tool(tool: str, install_hint: str) -> bool:
"""Check if a tool is installed."""
# Special handling for Claude CLI after `claude migrate-installer`
# See: https://github.com/github/spec-kit/issues/123
# The migrate-installer command REMOVES the original executable from PATH
# and creates an alias at ~/.claude/local/claude instead
# This path should be prioritized over other claude executables in PATH
if tool == "claude":
if CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file():
return True
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, *, script_type: str = "sh", verbose: bool = True, show_progress: bool = True, client: httpx.Client = None, debug: bool = False) -> 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, timeout=30, follow_redirects=True)
status = response.status_code
if status != 200:
msg = f"GitHub API returned {status} for {api_url}"
if debug:
msg += f"\nResponse headers: {response.headers}\nBody (truncated 500): {response.text[:500]}"
raise RuntimeError(msg)
try:
release_data = response.json()
except ValueError as je:
raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}")
except Exception as e:
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 (with fallback for Codex)
assets = release_data.get("assets", [])
pattern = f"spec-kit-template-{ai_assistant}-{script_type}"
matching_assets = [
asset for asset in assets
if pattern in asset["name"] and asset["name"].endswith(".zip")
]
asset = matching_assets[0] if matching_assets else None
if asset is None and ai_assistant == "codex":
fallback_pattern = f"spec-kit-template-copilot-{script_type}"
fallback_assets = [
asset for asset in assets
if fallback_pattern in asset["name"] and asset["name"].endswith(".zip")
]
if fallback_assets:
asset = fallback_assets[0]
if verbose:
console.print("[yellow]No Codex-specific template found; falling back to GitHub Copilot template.[/yellow]")
if asset is None:
console.print(
f"[red]No matching release asset found[/red] for AI assistant "
f"[bold]{ai_assistant}[/bold] (pattern: {pattern})"
)
asset_names = [a.get('name', '?') for a in assets]
console.print(Panel("\n".join(asset_names) or "(no assets)", title="Available Assets", border_style="yellow"))
raise typer.Exit(1)
# Use the resolved asset
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 client.stream("GET", download_url, timeout=60, follow_redirects=True) as response:
if response.status_code != 200:
body_sample = response.text[:400]
raise RuntimeError(f"Download failed with {response.status_code}\nHeaders: {response.headers}\nBody (truncated): {body_sample}")
total_size = int(response.headers.get('content-length', 0))
with open(zip_path, 'wb') as f:
if total_size == 0:
for chunk in response.iter_bytes(chunk_size=8192):
f.write(chunk)
else:
if show_progress:
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:
for chunk in response.iter_bytes(chunk_size=8192):
f.write(chunk)
except Exception as e:
console.print(f"[red]Error downloading template[/red]")
detail = str(e)
if zip_path.exists():
zip_path.unlink()
console.print(Panel(detail, title="Download Error", border_style="red"))
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, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False) -> 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,
script_type=script_type,
verbose=verbose and tracker is None,
show_progress=(tracker is None),
client=client,
debug=debug
)
if tracker:
tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)")
tracker.add("download", "Download template")
tracker.complete("download", meta['filename'])
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}")
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)
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
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
"""Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows)."""
if os.name == "nt":
return # Windows: skip silently
scripts_root = project_path / ".specify" / "scripts"
if not scripts_root.is_dir():
return
failures: list[str] = []
updated = 0
for script in scripts_root.rglob("*.sh"):
try:
if script.is_symlink() or not script.is_file():
continue
try:
with script.open("rb") as f:
if f.read(2) != b"#!":
continue
except Exception:
continue
st = script.stat(); mode = st.st_mode
if mode & 0o111:
continue
new_mode = mode
if mode & 0o400: new_mode |= 0o100
if mode & 0o040: new_mode |= 0o010
if mode & 0o004: new_mode |= 0o001
if not (new_mode & 0o100):
new_mode |= 0o100
os.chmod(script, new_mode)
updated += 1
except Exception as e:
failures.append(f"{script.relative_to(scripts_root)}: {e}")
if tracker:
detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "")
tracker.add("chmod", "Set script permissions recursively")
(tracker.error if failures else tracker.complete)("chmod", detail)
else:
if updated:
console.print(f"[cyan]Updated execute permissions on {updated} script(s) recursively[/cyan]")
if failures:
console.print("[yellow]Some scripts could not be updated:[/yellow]")
for f in failures:
console.print(f" - {f}")
@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, copilot, cursor, qwen, opencode, or codex"),
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
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"),
skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"),
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
):
"""
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, GitHub Copilot, Cursor, Qwen Code, opencode, or Codex CLI)
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 my-project --ai cursor
specify init my-project --ai qwen
specify init my-project --ai opencode
specify init my-project --ai codex
specify init --ignore-agent-tools my-project
specify init --here --ai claude
specify init --here --ai codex
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)
# 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")
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())}")
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
elif selected_ai == "qwen":
if not check_tool("qwen", "Install from: https://github.com/QwenLM/qwen-code"):
console.print("[red]Error:[/red] Qwen CLI is required for Qwen Code projects")
agent_tool_missing = True
elif selected_ai == "opencode":
if not check_tool("opencode", "Install from: https://opencode.ai"):
console.print("[red]Error:[/red] opencode CLI is required for opencode projects")
agent_tool_missing = True
elif selected_ai == "codex":
if not check_tool("codex", "Install from: https://github.com/openai/codex"):
console.print("[red]Error:[/red] Codex CLI is required for Codex projects")
agent_tool_missing = True
# GitHub Copilot and Cursor checks are not needed as they're 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)
# 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:
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")
# 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}")
tracker.add("script-select", "Select script type")
tracker.complete("script-select", selected_script)
for key, label in [
("fetch", "Fetch latest release"),
("download", "Download template"),
("extract", "Extract template"),
("zip-list", "Archive contents"),
("extracted-summary", "Extraction summary"),
("chmod", "Ensure scripts executable"),
("cleanup", "Cleanup"),
("git", "Initialize git repository"),
("final", "Finalize")
]:
tracker.add(key, label)
if selected_ai == "codex":
tracker.add("commands", "Ensure Codex commands")
# 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)
# Ensure scripts are executable (POSIX)
ensure_executable_scripts(project_path, tracker=tracker)
# Codex only: if commands/ is missing, copy from template (with embedded fallback)
if selected_ai == "codex":
tracker.start("commands")
try:
target_cmds = project_path / "commands"
if not target_cmds.exists():
commands_src = None
for ancestor in Path(__file__).resolve().parents:
candidate = ancestor / "templates" / "commands"
if candidate.exists() and candidate.is_dir():
commands_src = candidate
break
if commands_src is not None:
shutil.copytree(commands_src, target_cmds, dirs_exist_ok=True)
ensure_codex_command_templates_current(target_cmds)
tracker.complete("commands", "added")
else:
template_commands = project_path / ".specify" / "templates" / "commands"
if template_commands.exists():
shutil.copytree(template_commands, target_cmds, dirs_exist_ok=True)
ensure_codex_command_templates_current(target_cmds)
tracker.complete("commands", "added from template")
else:
target_cmds.mkdir(parents=True, exist_ok=True)
(target_cmds / "specify.md").write_text(CODEX_CMD_SPECIFY, encoding="utf-8")
(target_cmds / "plan.md").write_text(CODEX_CMD_PLAN, encoding="utf-8")
(target_cmds / "tasks.md").write_text(CODEX_CMD_TASKS, encoding="utf-8")
tracker.complete("commands", "bootstrapped minimal")
else:
tracker.skip("commands", "already present")
except Exception as codex_error:
tracker.error("commands", str(codex_error))
# Git step
if not no_git:
tracker.start("git")
if is_git_repo(project_path):
tracker.complete("git", "existing repo detected")
elif should_init_git:
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))
console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red"))
if debug:
_env_pairs = [
("Python", sys.version.split()[0]),
("Platform", sys.platform),
("CWD", str(Path.cwd())),
]
_label_width = max(len(k) for k, _ in _env_pairs)
env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs]
console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta"))
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 /specify 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 /specify to create specifications")
steps_lines.append(" - Run gemini /plan to create implementation plans")
steps_lines.append(" - Run gemini /tasks to generate tasks")
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")
elif selected_ai == "qwen":
steps_lines.append(f"{step_num}. Use / commands with Qwen CLI")
steps_lines.append(" - Run qwen /specify to create specifications")
steps_lines.append(" - Run qwen /plan to create implementation plans")
steps_lines.append(" - Run qwen /tasks to generate tasks")
steps_lines.append(" - See QWEN.md for all available commands")
elif selected_ai == "opencode":
steps_lines.append(f"{step_num}. Use / commands with opencode")
steps_lines.append(" - Use /specify to create specifications")
steps_lines.append(" - Use /plan to create implementation plans")
steps_lines.append(" - Use /tasks to generate tasks")
elif selected_ai == "codex":
steps_lines.append(f"{step_num}. Use / commands with Codex CLI")
steps_lines.append(" - Run codex /specify to create specifications")
steps_lines.append(" - Run codex /plan to create implementation plans")
steps_lines.append(" - Run codex /tasks to generate task lists")
steps_lines.append(" - See AGENTS.md for bundled commands")
# Removed script variant step (scripts are transparent to users)
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 for installed tools...[/bold]\n")
# Create tracker for checking tools
tracker = StepTracker("Check Available Tools")
# Add all tools we want to check
tracker.add("git", "Git version control")
tracker.add("claude", "Claude Code CLI")
tracker.add("gemini", "Gemini CLI")
tracker.add("qwen", "Qwen Code CLI")
tracker.add("code", "VS Code (for GitHub Copilot)")
tracker.add("cursor-agent", "Cursor IDE agent (optional)")
tracker.add("opencode", "opencode")
tracker.add("codex", "Codex CLI")
# Check each tool
git_ok = check_tool_for_tracker("git", "https://git-scm.com/downloads", tracker)
claude_ok = check_tool_for_tracker("claude", "https://docs.anthropic.com/en/docs/claude-code/setup", tracker)
gemini_ok = check_tool_for_tracker("gemini", "https://github.com/google-gemini/gemini-cli", tracker)
qwen_ok = check_tool_for_tracker("qwen", "https://github.com/QwenLM/qwen-code", tracker)
# Check for VS Code (code or code-insiders)
code_ok = check_tool_for_tracker("code", "https://code.visualstudio.com/", tracker)
if not code_ok:
code_ok = check_tool_for_tracker("code-insiders", "https://code.visualstudio.com/insiders/", tracker)
cursor_ok = check_tool_for_tracker("cursor-agent", "https://cursor.sh/", tracker)
opencode_ok = check_tool_for_tracker("opencode", "https://opencode.ai/", tracker)
codex_ok = check_tool_for_tracker("codex", "https://github.com/openai/codex", tracker)
# Render the final tree
console.print(tracker.render())
# Summary
console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
# Recommendations
if not git_ok:
console.print("[dim]Tip: Install git for repository management[/dim]")
if not (claude_ok or gemini_ok or cursor_ok or qwen_ok or opencode_ok or codex_ok):
console.print("[yellow]Consider installing an AI assistant for the best experience[/yellow]")
console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]")
def main():
app()
if __name__ == "__main__":
main()