2025-08-22 10:06:25 -07:00
#!/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>
2025-09-24 22:41:50 -07:00
uvx specify-cli.py init .
2025-08-22 10:06:25 -07:00
uvx specify-cli.py init --here
2025-09-05 02:17:29 +09:00
2025-08-22 10:06:25 -07:00
Or install globally:
uv tool install --from specify-cli.py specify-cli
specify init <project-name>
2025-09-24 22:41:50 -07:00
specify init .
2025-08-22 10:06:25 -07:00
specify init --here
"""
import os
import subprocess
import sys
import zipfile
import tempfile
import shutil
2025-09-21 00:44:20 -07:00
import shlex
2025-08-22 10:06:25 -07:00
import json
from pathlib import Path
2025-09-12 10:39:51 -07:00
from typing import Optional , Tuple
2025-08-22 10:06:25 -07:00
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
2025-09-09 21:30:45 -04:00
import ssl
import truststore
ssl_context = truststore . SSLContext ( ssl . PROTOCOL_TLS_CLIENT )
client = httpx . Client ( verify = ssl_context )
2025-08-22 10:06:25 -07:00
2025-09-17 17:05:49 +08:00
def _github_token ( cli_token : str | None = None ) -> str | None :
2025-09-20 08:46:32 -07:00
"""Return sanitized GitHub token (cli arg takes precedence) or None."""
return (( cli_token or os . getenv ( "GH_TOKEN" ) or os . getenv ( "GITHUB_TOKEN" ) or "" ) . strip ()) or None
2025-09-15 00:06:08 +08:00
2025-09-17 17:05:49 +08:00
def _github_auth_headers ( cli_token : str | None = None ) -> dict :
2025-09-20 08:46:32 -07:00
"""Return Authorization header dict only when a non-empty token exists."""
2025-09-17 17:05:49 +08:00
token = _github_token ( cli_token )
2025-09-20 08:46:32 -07:00
return { "Authorization" : f "Bearer { token } " } if token else {}
2025-09-15 00:06:08 +08:00
2025-10-10 11:12:54 -07:00
# Agent configuration with name, folder, install URL, and CLI tool requirement
AGENT_CONFIG = {
"copilot" : {
"name" : "GitHub Copilot" ,
"folder" : ".github/" ,
"install_url" : None , # IDE-based, no CLI check needed
"requires_cli" : False ,
},
"claude" : {
"name" : "Claude Code" ,
"folder" : ".claude/" ,
"install_url" : "https://docs.anthropic.com/en/docs/claude-code/setup" ,
"requires_cli" : True ,
},
"gemini" : {
"name" : "Gemini CLI" ,
"folder" : ".gemini/" ,
"install_url" : "https://github.com/google-gemini/gemini-cli" ,
"requires_cli" : True ,
},
2025-10-10 11:45:51 -07:00
"cursor-agent" : {
2025-10-10 11:12:54 -07:00
"name" : "Cursor" ,
"folder" : ".cursor/" ,
"install_url" : None , # IDE-based
"requires_cli" : False ,
},
"qwen" : {
"name" : "Qwen Code" ,
"folder" : ".qwen/" ,
"install_url" : "https://github.com/QwenLM/qwen-code" ,
"requires_cli" : True ,
},
"opencode" : {
"name" : "opencode" ,
"folder" : ".opencode/" ,
"install_url" : "https://opencode.ai" ,
"requires_cli" : True ,
},
"codex" : {
"name" : "Codex CLI" ,
"folder" : ".codex/" ,
"install_url" : "https://github.com/openai/codex" ,
"requires_cli" : True ,
},
"windsurf" : {
"name" : "Windsurf" ,
"folder" : ".windsurf/" ,
"install_url" : None , # IDE-based
"requires_cli" : False ,
},
"kilocode" : {
"name" : "Kilo Code" ,
"folder" : ".kilocode/" ,
"install_url" : None , # IDE-based
"requires_cli" : False ,
},
"auggie" : {
"name" : "Auggie CLI" ,
"folder" : ".augment/" ,
"install_url" : "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli" ,
"requires_cli" : True ,
},
"codebuddy" : {
"name" : "CodeBuddy" ,
"folder" : ".codebuddy/" ,
2025-10-15 21:34:40 +08:00
"install_url" : "https://www.codebuddy.ai/cli" ,
2025-10-10 11:12:54 -07:00
"requires_cli" : True ,
},
"roo" : {
"name" : "Roo Code" ,
"folder" : ".roo/" ,
"install_url" : None , # IDE-based
"requires_cli" : False ,
},
"q" : {
"name" : "Amazon Q Developer CLI" ,
"folder" : ".amazonq/" ,
"install_url" : "https://aws.amazon.com/developer/learning/q-developer-cli/" ,
"requires_cli" : True ,
},
2025-08-22 10:06:25 -07:00
}
2025-10-06 21:26:07 -07:00
2025-09-12 10:27:43 -07:00
SCRIPT_TYPE_CHOICES = { "sh" : "POSIX Shell (bash/zsh)" , "ps" : "PowerShell" }
2025-08-22 10:06:25 -07:00
2025-09-09 13:40:54 -07:00
CLAUDE_LOCAL_PATH = Path . home () / ".claude" / "local" / "claude"
2025-08-22 10:06:25 -07:00
BANNER = """
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝
╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝
███████║██║ ███████╗╚██████╗██║██║ ██║
╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝
"""
2025-09-18 23:13:38 -07:00
TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit"
2025-08-22 10:06:25 -07:00
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
2025-10-10 11:22:57 -07:00
2025-08-22 10:06:25 -07:00
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 ):
2025-09-20 00:21:01 -07:00
tree = Tree ( f "[cyan] { self . title } [/cyan]" , guide_style = "grey50" )
2025-08-22 10:06:25 -07:00
for step in self . steps :
label = step [ "label" ]
detail_text = step [ "detail" ] . strip () if step [ "detail" ] else ""
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
def get_key ():
"""Get a single keypress in a cross-platform way using readchar."""
key = readchar . readkey ()
2025-10-06 21:26:07 -07:00
2025-09-25 13:08:42 +08:00
if key == readchar . key . UP or key == readchar . key . CTRL_P :
2025-08-22 10:06:25 -07:00
return 'up'
2025-09-25 13:08:42 +08:00
if key == readchar . key . DOWN or key == readchar . key . CTRL_N :
2025-08-22 10:06:25 -07:00
return 'down'
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if key == readchar . key . ENTER :
return 'enter'
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if key == readchar . key . ESC :
return 'escape'
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
selected_key = None
def create_selection_panel ():
"""Create the selection panel with current selection highlighted."""
table = Table . grid ( padding = ( 0 , 2 ))
2025-09-20 00:17:10 -07:00
table . add_column ( style = "cyan" , justify = "left" , width = 3 )
2025-08-22 10:06:25 -07:00
table . add_column ( style = "white" , justify = "left" )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
for i , key in enumerate ( option_keys ):
if i == selected_index :
2025-09-20 00:17:10 -07:00
table . add_row ( "▶" , f "[cyan] { key } [/cyan] [dim]( { options [ key ] } )[/dim]" )
2025-08-22 10:06:25 -07:00
else :
2025-09-20 00:17:10 -07:00
table . add_row ( " " , f "[cyan] { key } [/cyan] [dim]( { options [ key ] } )[/dim]" )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
table . add_row ( "" , "" )
table . add_row ( "" , "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]" )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
return Panel (
table ,
title = f "[bold] { prompt_text } [/bold]" ,
border_style = "cyan" ,
padding = ( 1 , 2 )
)
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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 )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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 )
return selected_key
console = Console ()
class BannerGroup ( TyperGroup ):
"""Custom group that shows banner before help."""
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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."""
banner_lines = BANNER . strip () . split ( ' \n ' )
colors = [ "bright_blue" , "blue" , "cyan" , "bright_cyan" , "white" , "bright_white" ]
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
styled_banner = Text ()
for i , line in enumerate ( banner_lines ):
color = colors [ i % len ( colors )]
styled_banner . append ( line + " \n " , style = color )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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."""
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
2025-10-10 11:49:51 -07:00
def check_tool ( tool : str , tracker : StepTracker = None ) -> bool :
2025-10-10 11:12:54 -07:00
"""Check if a tool is installed. Optionally update tracker.
2025-09-09 13:39:00 -07:00
2025-10-10 11:12:54 -07:00
Args:
tool: Name of the tool to check
tracker: Optional StepTracker to update with results
Returns:
True if tool is found, False otherwise
"""
2025-09-09 13:39:00 -07:00
# 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" :
2025-09-09 13:40:54 -07:00
if CLAUDE_LOCAL_PATH . exists () and CLAUDE_LOCAL_PATH . is_file ():
2025-10-10 11:12:54 -07:00
if tracker :
tracker . complete ( tool , "available" )
2025-09-09 13:39:00 -07:00
return True
2025-10-10 11:12:54 -07:00
found = shutil . which ( tool ) is not None
if tracker :
if found :
tracker . complete ( tool , "available" )
else :
tracker . error ( tool , "not found" )
return found
2025-08-22 10:06:25 -07:00
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
2025-10-10 11:35:05 -07:00
def init_git_repo ( project_path : Path , quiet : bool = False ) -> Tuple [ bool , Optional [ str ]]:
2025-08-22 10:06:25 -07:00
"""Initialize a git repository in the specified path.
2025-10-10 11:35:05 -07:00
Args:
project_path: Path to initialize git repository in
quiet: if True suppress console output (tracker handles status)
Returns:
Tuple of (success: bool, error_message: Optional[str])
2025-08-22 10:06:25 -07:00
"""
try :
original_cwd = Path . cwd ()
os . chdir ( project_path )
if not quiet :
console . print ( "[cyan]Initializing git repository...[/cyan]" )
2025-10-10 11:35:05 -07:00
subprocess . run ([ "git" , "init" ], check = True , capture_output = True , text = True )
subprocess . run ([ "git" , "add" , "." ], check = True , capture_output = True , text = True )
subprocess . run ([ "git" , "commit" , "-m" , "Initial commit from Specify template" ], check = True , capture_output = True , text = True )
2025-08-22 10:06:25 -07:00
if not quiet :
console . print ( "[green]✓[/green] Git repository initialized" )
2025-10-10 11:35:05 -07:00
return True , None
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
except subprocess . CalledProcessError as e :
2025-10-10 11:35:05 -07:00
error_msg = f "Command: { ' ' . join ( e . cmd ) } \n Exit code: { e . returncode } "
if e . stderr :
error_msg += f " \n Error: { e . stderr . strip () } "
elif e . stdout :
error_msg += f " \n Output: { e . stdout . strip () } "
2025-08-22 10:06:25 -07:00
if not quiet :
console . print ( f "[red]Error initializing git repository:[/red] { e } " )
2025-10-10 11:35:05 -07:00
return False , error_msg
2025-08-22 10:06:25 -07:00
finally :
os . chdir ( original_cwd )
2025-10-17 00:24:28 +08:00
def handle_vscode_settings ( sub_item , dest_file , rel_path , verbose = False , tracker = None ) -> None :
2025-10-17 00:16:04 +08:00
"""Handle merging or copying of .vscode/settings.json files."""
def log ( message , color = "green" ):
if verbose and not tracker :
console . print ( f "[ { color } ] { message } [/] { rel_path } " )
try :
with open ( sub_item , 'r' , encoding = 'utf-8' ) as f :
new_settings = json . load ( f )
if dest_file . exists ():
merged = merge_json_files ( dest_file , new_settings , verbose = verbose and not tracker )
with open ( dest_file , 'w' , encoding = 'utf-8' ) as f :
json . dump ( merged , f , indent = 4 )
f . write ( ' \n ' )
log ( "Merged:" , "green" )
else :
shutil . copy2 ( sub_item , dest_file )
log ( "Copied (no existing settings.json):" , "blue" )
except Exception as e :
log ( f "Warning: Could not merge, copying instead: { e } " , "yellow" )
shutil . copy2 ( sub_item , dest_file )
def merge_json_files ( existing_path : Path , new_content : dict , verbose : bool = False ) -> dict :
"""Merge new JSON content into existing JSON file.
Performs a deep merge where:
- New keys are added
- Existing keys are preserved unless overwritten by new content
- Nested dictionaries are merged recursively
- Lists and other values are replaced (not merged)
Args:
existing_path: Path to existing JSON file
new_content: New JSON content to merge in
verbose: Whether to print merge details
Returns:
Merged JSON content as dict
"""
try :
with open ( existing_path , 'r' , encoding = 'utf-8' ) as f :
existing_content = json . load ( f )
except ( FileNotFoundError , json . JSONDecodeError ):
# If file doesn't exist or is invalid, just use new content
return new_content
def deep_merge ( base : dict , update : dict ) -> dict :
"""Recursively merge update dict into base dict."""
result = base . copy ()
for key , value in update . items ():
if key in result and isinstance ( result [ key ], dict ) and isinstance ( value , dict ):
# Recursively merge nested dictionaries
result [ key ] = deep_merge ( result [ key ], value )
else :
# Add new key or replace existing value
result [ key ] = value
return result
merged = deep_merge ( existing_content , new_content )
if verbose :
console . print ( f "[cyan]Merged JSON file:[/cyan] { existing_path . name } " )
return merged
2025-09-17 17:05:49 +08:00
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 ]:
2025-08-22 10:07:28 -07:00
repo_owner = "github"
repo_name = "spec-kit"
2025-09-10 22:27:38 -04:00
if client is None :
client = httpx . Client ( verify = ssl_context )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if verbose :
console . print ( "[cyan]Fetching latest release information...[/cyan]" )
api_url = f "https://api.github.com/repos/ { repo_owner } / { repo_name } /releases/latest"
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
try :
2025-09-15 00:06:08 +08:00
response = client . get (
api_url ,
timeout = 30 ,
follow_redirects = True ,
2025-09-20 08:46:32 -07:00
headers = _github_auth_headers ( github_token ),
2025-09-15 00:06:08 +08:00
)
2025-09-12 10:39:51 -07:00
status = response . status_code
if status != 200 :
msg = f "GitHub API returned { status } for { api_url } "
if debug :
msg += f " \n Response headers: { response . headers } \n Body (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 } \n Raw (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" ))
2025-08-22 10:06:25 -07:00
raise typer . Exit ( 1 )
2025-10-06 21:26:07 -07:00
2025-09-04 14:47:24 +09:00
assets = release_data . get ( "assets" , [])
2025-09-12 10:27:43 -07:00
pattern = f "spec-kit-template- { ai_assistant } - { script_type } "
2025-08-22 10:06:25 -07:00
matching_assets = [
2025-09-04 14:47:24 +09:00
asset for asset in assets
2025-08-22 10:06:25 -07:00
if pattern in asset [ "name" ] and asset [ "name" ] . endswith ( ".zip" )
]
2025-09-04 14:47:24 +09:00
asset = matching_assets [ 0 ] if matching_assets else None
if asset is None :
2025-09-20 10:06:59 -07:00
console . print ( f "[red]No matching release asset found[/red] for [bold] { ai_assistant } [/bold] (expected pattern: [bold] { pattern } [/bold])" )
2025-09-16 18:11:35 +09:00
asset_names = [ a . get ( 'name' , '?' ) for a in assets ]
2025-09-12 10:39:51 -07:00
console . print ( Panel ( " \n " . join ( asset_names ) or "(no assets)" , title = "Available Assets" , border_style = "yellow" ))
2025-08-22 10:06:25 -07:00
raise typer . Exit ( 1 )
2025-09-04 14:47:24 +09:00
2025-08-22 10:06:25 -07:00
download_url = asset [ "browser_download_url" ]
filename = asset [ "name" ]
file_size = asset [ "size" ]
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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' ] } " )
2025-09-20 10:06:59 -07:00
2025-08-22 10:06:25 -07:00
zip_path = download_dir / filename
if verbose :
console . print ( f "[cyan]Downloading template...[/cyan]" )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
try :
2025-09-15 00:06:08 +08:00
with client . stream (
"GET" ,
download_url ,
timeout = 60 ,
follow_redirects = True ,
2025-09-20 08:46:32 -07:00
headers = _github_auth_headers ( github_token ),
2025-09-15 00:06:08 +08:00
) as response :
2025-09-12 10:39:51 -07:00
if response . status_code != 200 :
body_sample = response . text [: 400 ]
raise RuntimeError ( f "Download failed with { response . status_code } \n Headers: { response . headers } \n Body (truncated): { body_sample } " )
2025-08-22 10:06:25 -07:00
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 )
2025-09-12 10:39:51 -07:00
except Exception as e :
console . print ( f "[red]Error downloading template[/red]" )
detail = str ( e )
2025-08-22 10:06:25 -07:00
if zip_path . exists ():
zip_path . unlink ()
2025-09-12 10:39:51 -07:00
console . print ( Panel ( detail , title = "Download Error" , border_style = "red" ))
2025-08-22 10:06:25 -07:00
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
2025-09-17 17:05:49 +08:00
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 :
2025-08-22 10:06:25 -07:00
"""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 ()
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if tracker :
tracker . start ( "fetch" , "contacting GitHub API" )
try :
zip_path , meta = download_template_from_github (
ai_assistant ,
current_dir ,
2025-09-12 10:27:43 -07:00
script_type = script_type ,
2025-08-22 10:06:25 -07:00
verbose = verbose and tracker is None ,
2025-09-10 22:27:38 -04:00
show_progress = ( tracker is None ),
2025-09-12 10:39:51 -07:00
client = client ,
2025-09-17 17:05:49 +08:00
debug = debug ,
github_token = github_token
2025-08-22 10:06:25 -07:00
)
if tracker :
tracker . complete ( "fetch" , f "release { meta [ 'release' ] } ( { meta [ 'size' ] : , } bytes)" )
tracker . add ( "download" , "Download template" )
2025-09-10 22:27:38 -04:00
tracker . complete ( "download" , meta [ 'filename' ])
2025-08-22 10:06:25 -07:00
except Exception as e :
if tracker :
tracker . error ( "fetch" , str ( e ))
else :
if verbose :
console . print ( f "[red]Error downloading template:[/red] { e } " )
raise
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if tracker :
tracker . add ( "extract" , "Extract template" )
tracker . start ( "extract" )
elif verbose :
console . print ( "Extracting template..." )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
try :
if not is_current_dir :
project_path . mkdir ( parents = True )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
with zipfile . ZipFile ( zip_path , 'r' ) as zip_ref :
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]" )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if is_current_dir :
with tempfile . TemporaryDirectory () as temp_dir :
temp_path = Path ( temp_dir )
zip_ref . extractall ( temp_path )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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]" )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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]" )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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 } " )
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 )
2025-10-17 00:16:04 +08:00
# Special handling for .vscode/settings.json - merge instead of overwrite
if dest_file . name == "settings.json" and dest_file . parent . name == ".vscode" :
handle_vscode_settings ( sub_item , dest_file , rel_path , verbose , tracker )
else :
shutil . copy2 ( sub_item , dest_file )
2025-08-22 10:06:25 -07:00
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 :
zip_ref . extractall ( project_path )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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' } )" )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if len ( extracted_items ) == 1 and extracted_items [ 0 ] . is_dir ():
nested_dir = extracted_items [ 0 ]
temp_move_dir = project_path . parent / f " { project_path . name } _temp"
2025-10-10 11:22:57 -07:00
2025-08-22 10:06:25 -07:00
shutil . move ( str ( nested_dir ), str ( temp_move_dir ))
2025-10-10 11:22:57 -07:00
2025-08-22 10:06:25 -07:00
project_path . rmdir ()
2025-10-10 11:22:57 -07:00
2025-08-22 10:06:25 -07:00
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]" )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
except Exception as e :
if tracker :
tracker . error ( "extract" , str ( e ))
else :
if verbose :
console . print ( f "[red]Error extracting template:[/red] { e } " )
2025-09-12 10:39:51 -07:00
if debug :
console . print ( Panel ( str ( e ), title = "Extraction Error" , border_style = "red" ))
2025-10-10 11:22:57 -07:00
2025-08-22 10:06:25 -07:00
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" )
2025-10-10 11:22:57 -07:00
2025-08-22 10:06:25 -07:00
if zip_path . exists ():
zip_path . unlink ()
if tracker :
tracker . complete ( "cleanup" )
elif verbose :
console . print ( f "Cleaned up: { zip_path . name } " )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
return project_path
2025-09-11 22:06:04 -07:00
def ensure_executable_scripts ( project_path : Path , tracker : StepTracker | None = None ) -> None :
2025-09-12 10:27:43 -07:00
"""Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows)."""
2025-09-11 22:06:04 -07:00
if os . name == "nt" :
return # Windows: skip silently
2025-09-12 10:27:43 -07:00
scripts_root = project_path / ".specify" / "scripts"
if not scripts_root . is_dir ():
2025-09-11 22:06:04 -07:00
return
failures : list [ str ] = []
updated = 0
2025-09-12 10:27:43 -07:00
for script in scripts_root . rglob ( "*.sh" ):
2025-09-11 22:06:04 -07:00
try :
2025-09-12 10:27:43 -07:00
if script . is_symlink () or not script . is_file ():
2025-09-11 22:06:04 -07:00
continue
try :
with script . open ( "rb" ) as f :
2025-09-12 10:27:43 -07:00
if f . read ( 2 ) != b "#!" :
continue
2025-09-11 22:06:04 -07:00
except Exception :
continue
2025-09-12 10:27:43 -07:00
st = script . stat (); mode = st . st_mode
2025-09-11 22:06:04 -07:00
if mode & 0o111 :
continue
new_mode = mode
2025-09-12 10:27:43 -07:00
if mode & 0o400 : new_mode |= 0o100
if mode & 0o040 : new_mode |= 0o010
if mode & 0o004 : new_mode |= 0o001
2025-09-11 22:06:04 -07:00
if not ( new_mode & 0o100 ):
new_mode |= 0o100
os . chmod ( script , new_mode )
updated += 1
except Exception as e :
2025-09-12 10:27:43 -07:00
failures . append ( f " { script . relative_to ( scripts_root ) } : { e } " )
2025-09-11 22:06:04 -07:00
if tracker :
detail = f " { updated } updated" + ( f ", { len ( failures ) } failed" if failures else "" )
2025-09-12 10:27:43 -07:00
tracker . add ( "chmod" , "Set script permissions recursively" )
( tracker . error if failures else tracker . complete )( "chmod" , detail )
2025-09-11 22:06:04 -07:00
else :
if updated :
2025-09-12 10:27:43 -07:00
console . print ( f "[cyan]Updated execute permissions on { updated } script(s) recursively[/cyan]" )
2025-09-11 22:06:04 -07:00
if failures :
console . print ( "[yellow]Some scripts could not be updated:[/yellow]" )
for f in failures :
console . print ( f " - { f } " )
2025-08-22 10:06:25 -07:00
@app.command ()
def init (
2025-09-24 22:41:50 -07:00
project_name : str = typer . Argument ( None , help = "Name for your new project directory (optional if using --here, or use '.' for current directory)" ),
2025-10-10 11:45:51 -07:00
ai_assistant : str = typer . Option ( None , "--ai" , help = "AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, or q" ),
2025-09-12 10:27:43 -07:00
script_type : str = typer . Option ( None , "--script" , help = "Script type to use: sh or ps" ),
2025-08-22 10:06:25 -07:00
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" ),
2025-09-22 14:45:35 -07:00
force : bool = typer . Option ( False , "--force" , help = "Force merge/overwrite when using --here (skip confirmation)" ),
2025-09-10 22:27:38 -04:00
skip_tls : bool = typer . Option ( False , "--skip-tls" , help = "Skip SSL/TLS verification (not recommended)" ),
2025-09-12 10:39:51 -07:00
debug : bool = typer . Option ( False , "--debug" , help = "Show verbose diagnostic output for network and extraction failures" ),
2025-09-17 17:05:49 +08:00
github_token : str = typer . Option ( None , "--github-token" , help = "GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)" ),
2025-08-22 10:06:25 -07:00
):
"""
Initialize a new Specify project from the latest template.
This command will:
1. Check that required tools are installed (git is optional)
2025-10-10 11:03:13 -07:00
2. Let you choose your AI assistant
2025-08-22 10:06:25 -07:00
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 copilot --no-git
specify init --ignore-agent-tools my-project
2025-09-24 22:41:50 -07:00
specify init . --ai claude # Initialize in current directory
specify init . # Initialize in current directory (interactive AI selection)
specify init --here --ai claude # Alternative syntax for current directory
2025-09-04 14:47:24 +09:00
specify init --here --ai codex
2025-09-22 14:20:31 +08:00
specify init --here --ai codebuddy
2025-08-22 10:06:25 -07:00
specify init --here
2025-09-22 14:45:35 -07:00
specify init --here --force # Skip confirmation when current directory not empty
2025-08-22 10:06:25 -07:00
"""
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
show_banner ()
2025-10-06 21:26:07 -07:00
2025-09-24 22:41:50 -07:00
if project_name == "." :
here = True
2025-09-25 11:16:05 -07:00
project_name = None # Clear project_name to use existing validation logic
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if here and project_name :
console . print ( "[red]Error:[/red] Cannot specify both project name and --here flag" )
raise typer . Exit ( 1 )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if not here and not project_name :
2025-09-24 22:41:50 -07:00
console . print ( "[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag" )
2025-08-22 10:06:25 -07:00
raise typer . Exit ( 1 )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if here :
project_name = Path . cwd () . name
project_path = Path . cwd ()
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
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]" )
2025-09-22 14:45:35 -07:00
if force :
console . print ( "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" )
else :
response = typer . confirm ( "Do you want to continue?" )
if not response :
console . print ( "[yellow]Operation cancelled[/yellow]" )
raise typer . Exit ( 0 )
2025-08-22 10:06:25 -07:00
else :
project_path = Path ( project_name ) . resolve ()
if project_path . exists ():
2025-09-21 13:12:49 -07:00
error_panel = Panel (
f "Directory '[cyan] { project_name } [/cyan]' already exists \n "
"Please choose a different project name or remove the existing directory." ,
title = "[red]Directory Conflict[/red]" ,
border_style = "red" ,
padding = ( 1 , 2 )
)
console . print ()
console . print ( error_panel )
2025-08-22 10:06:25 -07:00
raise typer . Exit ( 1 )
2025-10-06 21:26:07 -07:00
2025-09-20 00:24:06 -07:00
current_dir = Path . cwd ()
2025-10-06 21:26:07 -07:00
2025-09-20 00:24:06 -07:00
setup_lines = [
"[cyan]Specify Project Setup[/cyan]" ,
"" ,
f " { 'Project' : <15 } [green] { project_path . name } [/green]" ,
f " { 'Working Path' : <15 } [dim] { current_dir } [/dim]" ,
]
2025-10-06 21:26:07 -07:00
2025-09-20 00:24:06 -07:00
if not here :
setup_lines . append ( f " { 'Target Path' : <15 } [dim] { project_path } [/dim]" )
2025-10-06 21:26:07 -07:00
2025-09-20 00:24:06 -07:00
console . print ( Panel ( " \n " . join ( setup_lines ), border_style = "cyan" , padding = ( 1 , 2 )))
2025-10-06 21:26:07 -07:00
2025-09-17 11:28:52 +02:00
should_init_git = False
2025-08-22 10:06:25 -07:00
if not no_git :
2025-10-10 11:58:10 -07:00
should_init_git = check_tool ( "git" )
2025-09-17 11:28:52 +02:00
if not should_init_git :
2025-08-22 10:06:25 -07:00
console . print ( "[yellow]Git not found - will skip repository initialization[/yellow]" )
if ai_assistant :
2025-10-10 11:22:57 -07:00
if ai_assistant not in AGENT_CONFIG :
console . print ( f "[red]Error:[/red] Invalid AI assistant ' { ai_assistant } '. Choose from: { ', ' . join ( AGENT_CONFIG . keys ()) } " )
2025-08-22 10:06:25 -07:00
raise typer . Exit ( 1 )
selected_ai = ai_assistant
else :
2025-10-10 11:22:57 -07:00
# Create options dict for selection (agent_key: display_name)
ai_choices = { key : config [ "name" ] for key , config in AGENT_CONFIG . items ()}
2025-08-22 10:06:25 -07:00
selected_ai = select_with_arrows (
2025-10-10 11:22:57 -07:00
ai_choices ,
2025-08-22 10:06:25 -07:00
"Choose your AI assistant:" ,
"copilot"
)
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
if not ignore_agent_tools :
2025-10-10 11:12:54 -07:00
agent_config = AGENT_CONFIG . get ( selected_ai )
if agent_config and agent_config [ "requires_cli" ]:
install_url = agent_config [ "install_url" ]
2025-10-10 11:58:10 -07:00
if not check_tool ( selected_ai ):
2025-10-10 11:12:54 -07:00
error_panel = Panel (
f "[cyan] { selected_ai } [/cyan] not found \n "
f "Install from: [cyan] { install_url } [/cyan] \n "
2025-10-10 11:22:57 -07:00
f " { agent_config [ 'name' ] } is required to continue with this project type. \n\n "
2025-10-10 11:12:54 -07:00
"Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check" ,
title = "[red]Agent Detection Error[/red]" ,
border_style = "red" ,
padding = ( 1 , 2 )
)
console . print ()
console . print ( error_panel )
raise typer . Exit ( 1 )
2025-10-06 21:26:07 -07:00
2025-09-12 10:27:43 -07:00
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 :
default_script = "ps" if os . name == "nt" else "sh"
2025-10-10 11:22:57 -07:00
2025-09-12 10:27:43 -07:00
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
2025-10-06 21:26:07 -07:00
2025-09-12 10:27:43 -07:00
console . print ( f "[cyan]Selected AI assistant:[/cyan] { selected_ai } " )
console . print ( f "[cyan]Selected script type:[/cyan] { selected_script } " )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
tracker = StepTracker ( "Initialize Specify Project" )
2025-10-10 11:22:57 -07:00
2025-08-22 10:06:25 -07:00
sys . _specify_tracker_active = True
2025-10-10 11:22:57 -07:00
2025-08-22 10:06:25 -07:00
tracker . add ( "precheck" , "Check required tools" )
tracker . complete ( "precheck" , "ok" )
tracker . add ( "ai-select" , "Select AI assistant" )
tracker . complete ( "ai-select" , f " { selected_ai } " )
2025-09-12 10:27:43 -07:00
tracker . add ( "script-select" , "Select script type" )
tracker . complete ( "script-select" , selected_script )
2025-08-22 10:06:25 -07:00
for key , label in [
( "fetch" , "Fetch latest release" ),
( "download" , "Download template" ),
( "extract" , "Extract template" ),
( "zip-list" , "Archive contents" ),
( "extracted-summary" , "Extraction summary" ),
2025-09-19 17:53:16 +09:00
( "chmod" , "Ensure scripts executable" ),
2025-08-22 10:06:25 -07:00
( "cleanup" , "Cleanup" ),
( "git" , "Initialize git repository" ),
( "final" , "Finalize" )
]:
tracker . add ( key , label )
2025-10-10 11:35:05 -07:00
# Track git error message outside Live context so it persists
git_error_message = None
2025-08-22 10:06:25 -07:00
with Live ( tracker . render (), console = console , refresh_per_second = 8 , transient = True ) as live :
tracker . attach_refresh ( lambda : live . update ( tracker . render ()))
try :
2025-09-10 22:27:38 -04:00
verify = not skip_tls
local_ssl_context = ssl_context if verify else False
local_client = httpx . Client ( verify = local_ssl_context )
2025-09-17 17:05:49 +08:00
download_and_extract_template ( project_path , selected_ai , selected_script , here , verbose = False , tracker = tracker , client = local_client , debug = debug , github_token = github_token )
2025-08-22 10:06:25 -07:00
2025-09-11 22:06:04 -07:00
ensure_executable_scripts ( project_path , tracker = tracker )
2025-08-22 10:06:25 -07:00
if not no_git :
tracker . start ( "git" )
if is_git_repo ( project_path ):
tracker . complete ( "git" , "existing repo detected" )
2025-09-17 11:28:52 +02:00
elif should_init_git :
2025-10-10 11:35:05 -07:00
success , error_msg = init_git_repo ( project_path , quiet = True )
if success :
2025-08-22 10:06:25 -07:00
tracker . complete ( "git" , "initialized" )
else :
tracker . error ( "git" , "init failed" )
2025-10-10 11:35:05 -07:00
git_error_message = error_msg
2025-08-22 10:06:25 -07:00
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 ))
2025-09-12 10:39:51 -07:00
console . print ( Panel ( f "Initialization failed: { e } " , title = "Failure" , border_style = "red" ))
if debug :
2025-09-12 13:15:41 -07:00
_env_pairs = [
( "Python" , sys . version . split ()[ 0 ]),
( "Platform" , sys . platform ),
( "CWD" , str ( Path . cwd ())),
2025-09-12 10:39:51 -07:00
]
2025-09-12 13:15:41 -07:00
_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" ))
2025-08-22 10:06:25 -07:00
if not here and project_path . exists ():
shutil . rmtree ( project_path )
raise typer . Exit ( 1 )
finally :
pass
console . print ( tracker . render ())
console . print ( " \n [bold green]Project ready.[/bold green]" )
2025-10-10 11:35:05 -07:00
# Show git error details if initialization failed
if git_error_message :
console . print ()
git_error_panel = Panel (
f "[yellow]Warning:[/yellow] Git repository initialization failed \n\n "
f " { git_error_message } \n\n "
f "[dim]You can initialize git manually later with:[/dim] \n "
f "[cyan]cd { project_path if not here else '.' } [/cyan] \n "
f "[cyan]git init[/cyan] \n "
f "[cyan]git add .[/cyan] \n "
f "[cyan]git commit -m \" Initial commit \" [/cyan]" ,
title = "[red]Git Initialization Failed[/red]" ,
border_style = "red" ,
padding = ( 1 , 2 )
)
console . print ( git_error_panel )
2025-10-06 21:26:07 -07:00
2025-09-21 12:48:58 -07:00
# Agent folder security notice
2025-10-10 11:22:57 -07:00
agent_config = AGENT_CONFIG . get ( selected_ai )
if agent_config :
agent_folder = agent_config [ "folder" ]
2025-09-21 12:48:58 -07:00
security_notice = Panel (
f "Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project. \n "
f "Consider adding [cyan] { agent_folder } [/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage." ,
title = "[yellow]Agent Folder Security[/yellow]" ,
border_style = "yellow" ,
padding = ( 1 , 2 )
)
console . print ()
console . print ( security_notice )
2025-10-06 21:26:07 -07:00
2025-08-22 10:06:25 -07:00
steps_lines = []
if not here :
2025-09-21 00:44:20 -07:00
steps_lines . append ( f "1. Go to the project folder: [cyan]cd { project_name } [/cyan]" )
2025-08-22 10:06:25 -07:00
step_num = 2
else :
steps_lines . append ( "1. You're already in the project directory!" )
step_num = 2
2025-09-21 00:44:20 -07:00
# Add Codex-specific setup step if needed
if selected_ai == "codex" :
codex_path = project_path / ".codex"
quoted_path = shlex . quote ( str ( codex_path ))
if os . name == "nt" : # Windows
cmd = f "setx CODEX_HOME { quoted_path } "
else : # Unix-like systems
cmd = f "export CODEX_HOME= { quoted_path } "
steps_lines . append ( f " { step_num } . Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan] { cmd } [/cyan]" )
step_num += 1
2025-09-18 23:03:01 -07:00
steps_lines . append ( f " { step_num } . Start using slash commands with your AI agent:" )
2025-09-23 18:29:15 -07:00
2025-10-06 22:16:48 -07:00
steps_lines . append ( " 2.1 [cyan]/speckit.constitution[/] - Establish project principles" )
steps_lines . append ( " 2.2 [cyan]/speckit.specify[/] - Create baseline specification" )
steps_lines . append ( " 2.3 [cyan]/speckit.plan[/] - Create implementation plan" )
steps_lines . append ( " 2.4 [cyan]/speckit.tasks[/] - Generate actionable tasks" )
steps_lines . append ( " 2.5 [cyan]/speckit.implement[/] - Execute implementation" )
2025-08-22 10:06:25 -07:00
2025-09-21 12:48:58 -07:00
steps_panel = Panel ( " \n " . join ( steps_lines ), title = "Next Steps" , border_style = "cyan" , padding = ( 1 , 2 ))
2025-09-18 23:03:01 -07:00
console . print ()
2025-08-22 10:06:25 -07:00
console . print ( steps_panel )
2025-09-23 18:29:15 -07:00
enhancement_lines = [
2025-09-25 14:05:15 -07:00
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]" ,
2025-09-23 18:29:15 -07:00
"" ,
2025-10-06 22:16:48 -07:00
f "○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)" ,
f "○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])" ,
f "○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
2025-09-23 18:29:15 -07:00
]
enhancements_panel = Panel ( " \n " . join ( enhancement_lines ), title = "Enhancement Commands" , border_style = "cyan" , padding = ( 1 , 2 ))
console . print ()
console . print ( enhancements_panel )
2025-08-22 10:06:25 -07:00
@app.command ()
2025-09-12 00:15:25 -07:00
def check ():
2025-08-22 10:06:25 -07:00
"""Check that all required tools are installed."""
show_banner ()
2025-09-12 00:15:25 -07:00
console . print ( "[bold]Checking for installed tools...[/bold] \n " )
2025-09-10 22:27:38 -04:00
2025-09-12 00:15:25 -07:00
tracker = StepTracker ( "Check Available Tools" )
2025-10-06 21:26:07 -07:00
2025-09-12 00:15:25 -07:00
tracker . add ( "git" , "Git version control" )
2025-10-10 11:12:54 -07:00
git_ok = check_tool ( "git" , tracker = tracker )
2025-10-17 23:53:47 +08:00
2025-10-10 11:12:54 -07:00
agent_results = {}
for agent_key , agent_config in AGENT_CONFIG . items ():
agent_name = agent_config [ "name" ]
2025-10-17 23:53:47 +08:00
requires_cli = agent_config [ "requires_cli" ]
2025-10-10 11:45:51 -07:00
tracker . add ( agent_key , agent_name )
2025-10-17 23:53:47 +08:00
if requires_cli :
agent_results [ agent_key ] = check_tool ( agent_key , tracker = tracker )
else :
# IDE-based agent - skip CLI check and mark as optional
tracker . skip ( agent_key , "IDE-based, no CLI check" )
agent_results [ agent_key ] = False # Don't count IDE agents as "found"
2025-10-10 11:12:54 -07:00
# Check VS Code variants (not in agent config)
2025-09-21 12:32:16 -07:00
tracker . add ( "code" , "Visual Studio Code" )
2025-10-10 11:12:54 -07:00
code_ok = check_tool ( "code" , tracker = tracker )
2025-10-17 23:53:47 +08:00
2025-09-21 12:32:16 -07:00
tracker . add ( "code-insiders" , "Visual Studio Code Insiders" )
2025-10-10 11:12:54 -07:00
code_insiders_ok = check_tool ( "code-insiders" , tracker = tracker )
2025-09-18 23:03:01 -07:00
2025-09-12 00:15:25 -07:00
console . print ( tracker . render ())
2025-09-16 18:11:35 +09:00
2025-09-12 00:15:25 -07:00
console . print ( " \n [bold green]Specify CLI is ready to use![/bold green]" )
2025-09-16 18:11:35 +09:00
2025-08-22 10:06:25 -07:00
if not git_ok :
2025-09-12 00:15:25 -07:00
console . print ( "[dim]Tip: Install git for repository management[/dim]" )
2025-08-22 10:06:25 -07:00
2025-10-10 11:12:54 -07:00
if not any ( agent_results . values ()):
2025-09-12 00:15:25 -07:00
console . print ( "[dim]Tip: Install an AI assistant for the best experience[/dim]" )
2025-08-22 10:06:25 -07:00
def main ():
app ()
if __name__ == "__main__" :
2025-09-03 19:24:27 +09:00
main ()
2025-10-14 11:52:26 -07:00