Merge branch 'main' into codebuddy

This commit is contained in:
Den Delimarsky
2025-10-10 11:03:13 -07:00
committed by GitHub
29 changed files with 1487 additions and 735 deletions

View File

@@ -64,7 +64,6 @@ def _github_auth_headers(cli_token: str | None = None) -> dict:
token = _github_token(cli_token)
return {"Authorization": f"Bearer {token}"} if token else {}
# Constants
AI_CHOICES = {
"copilot": "GitHub Copilot",
"claude": "Claude Code",
@@ -78,14 +77,13 @@ AI_CHOICES = {
"auggie": "Auggie CLI",
"codebuddy": "CodeBuddy",
"roo": "Roo Code",
"q": "Amazon Q Developer 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"
# ASCII Art Banner
BANNER = """
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
@@ -182,40 +180,26 @@ class StepTracker:
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 or key == readchar.key.CTRL_P:
return 'up'
if key == readchar.key.DOWN or key == readchar.key.CTRL_N:
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.
@@ -233,7 +217,7 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def
selected_index = option_keys.index(default_key)
else:
selected_index = 0
selected_key = None
def create_selection_panel():
@@ -241,23 +225,23 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def
table = Table.grid(padding=(0, 2))
table.add_column(style="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"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]")
else:
table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]")
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():
@@ -276,7 +260,7 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def
elif key == 'escape':
console.print("\n[yellow]Selection cancelled[/yellow]")
raise typer.Exit(1)
live.update(create_selection_panel(), refresh=True)
except KeyboardInterrupt:
@@ -292,14 +276,11 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def
# Suppress explicit selection print; tracker / later logic will report consolidated status
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()
@@ -314,23 +295,21 @@ app = typer.Typer(
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."""
@@ -341,7 +320,6 @@ def callback(ctx: typer.Context):
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:
@@ -360,7 +338,6 @@ def run_command(cmd: list[str], check_return: bool = True, capture: bool = False
raise
return None
def check_tool_for_tracker(tool: str, tracker: StepTracker) -> bool:
"""Check if a tool is installed and update tracker."""
if shutil.which(tool):
@@ -370,7 +347,6 @@ def check_tool_for_tracker(tool: str, tracker: StepTracker) -> bool:
tracker.error(tool, "not found")
return False
def check_tool(tool: str, install_hint: str) -> bool:
"""Check if a tool is installed."""
@@ -388,7 +364,6 @@ def check_tool(tool: str, install_hint: str) -> bool:
else:
return False
def is_git_repo(path: Path = None) -> bool:
"""Check if the specified path is inside a git repository."""
if path is None:
@@ -409,7 +384,6 @@ def is_git_repo(path: Path = None) -> bool:
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)
@@ -425,7 +399,7 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> bool:
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}")
@@ -433,17 +407,16 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> bool:
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, github_token: str = None) -> 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,
@@ -465,7 +438,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
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
assets = release_data.get("assets", [])
pattern = f"spec-kit-template-{ai_assistant}-{script_type}"
@@ -485,7 +458,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
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")
@@ -494,7 +467,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
zip_path = download_dir / filename
if verbose:
console.print(f"[cyan]Downloading template...[/cyan]")
try:
with client.stream(
"GET",
@@ -545,13 +518,12 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
}
return zip_path, metadata
def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path:
"""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")
@@ -577,18 +549,18 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
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()
@@ -597,13 +569,13 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
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:
@@ -611,7 +583,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
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():
@@ -621,7 +593,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
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
@@ -647,7 +619,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
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:
@@ -657,7 +629,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
console.print(f"[cyan]Extracted {len(extracted_items)} items to {project_path}:[/cyan]")
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
@@ -674,7 +646,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
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))
@@ -700,7 +672,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
tracker.complete("cleanup")
elif verbose:
console.print(f"Cleaned up: {zip_path.name}")
return project_path
@@ -751,7 +723,7 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
@app.command()
def init(
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor, qwen, opencode, codex, windsurf, kilocode, auggie, roo, or codebuddy"),
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, or q"),
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"),
@@ -766,7 +738,7 @@ def init(
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, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, or CodeBuddy)
2. Let you choose your AI assistant
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)
@@ -775,16 +747,7 @@ def init(
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 my-project --ai windsurf
specify init my-project --ai kilocode
specify init my-project --ai auggie
specify init my-project --ai codebuddy
specify init --ignore-agent-tools my-project
specify init . --ai claude # Initialize in current directory
specify init . # Initialize in current directory (interactive AI selection)
@@ -794,29 +757,26 @@ def init(
specify init --here
specify init --here --force # Skip confirmation when current directory not empty
"""
# Show banner first
show_banner()
# Handle '.' as shorthand for current directory (equivalent to --here)
if project_name == ".":
here = True
project_name = None # Clear project_name to use existing validation logic
# 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, use '.' for current directory, 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)")
@@ -824,14 +784,12 @@ def init(
if force:
console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]")
else:
# 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():
error_panel = Panel(
f"Directory '[cyan]{project_name}[/cyan]' already exists\n"
@@ -843,23 +801,22 @@ def init(
console.print()
console.print(error_panel)
raise typer.Exit(1)
# Create formatted setup info with column alignment
current_dir = Path.cwd()
setup_lines = [
"[cyan]Specify Project Setup[/cyan]",
"",
f"{'Project':<15} [green]{project_path.name}[/green]",
f"{'Working Path':<15} [dim]{current_dir}[/dim]",
]
# Add target path only if different from working dir
if not here:
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
# Check git only if we might need it (not --no-git)
# Only set to True if the user wants it and the tool is available
should_init_git = False
@@ -868,7 +825,6 @@ def init(
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())}")
@@ -881,7 +837,7 @@ def init(
"Choose your AI assistant:",
"copilot"
)
# Check agent tools unless ignored
if not ignore_agent_tools:
agent_tool_missing = False
@@ -914,6 +870,10 @@ def init(
if not check_tool("codebuddy", "https://www.codebuddy.ai"):
install_url = "https://www.codebuddy.ai"
agent_tool_missing = True
elif selected_ai == "q":
if not check_tool("q", "https://github.com/aws/amazon-q-developer-cli"):
install_url = "https://aws.amazon.com/developer/learning/q-developer-cli/"
agent_tool_missing = True
# GitHub Copilot and Cursor checks are not needed as they're typically available in supported IDEs
if agent_tool_missing:
@@ -929,7 +889,7 @@ def init(
console.print()
console.print(error_panel)
raise typer.Exit(1)
# Determine script type (explicit, interactive, or OS default)
if script_type:
if script_type not in SCRIPT_TYPE_CHOICES:
@@ -944,10 +904,10 @@ def init(
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")
@@ -1025,7 +985,7 @@ def init(
# Final static tree (ensures finished state visible after Live context ends)
console.print(tracker.render())
console.print("\n[bold green]Project ready.[/bold green]")
# Agent folder security notice
agent_folder_map = {
"claude": ".claude/",
@@ -1039,9 +999,10 @@ def init(
"auggie": ".augment/",
"codebuddy": ".codebuddy/",
"copilot": ".github/",
"roo": ".roo/"
"roo": ".roo/",
"q": ".amazonq/"
}
if selected_ai in agent_folder_map:
agent_folder = agent_folder_map[selected_ai]
security_notice = Panel(
@@ -1053,7 +1014,7 @@ def init(
)
console.print()
console.print(security_notice)
# Boxed "Next steps" section
steps_lines = []
if not here:
@@ -1077,11 +1038,11 @@ def init(
steps_lines.append(f"{step_num}. Start using slash commands with your AI agent:")
steps_lines.append(" 2.1 [cyan]/constitution[/] - Establish project principles")
steps_lines.append(" 2.2 [cyan]/specify[/] - Create baseline specification")
steps_lines.append(" 2.3 [cyan]/plan[/] - Create implementation plan")
steps_lines.append(" 2.4 [cyan]/tasks[/] - Generate actionable tasks")
steps_lines.append(" 2.5 [cyan]/implement[/] - Execute implementation")
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")
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2))
console.print()
@@ -1090,24 +1051,14 @@ def init(
enhancement_lines = [
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]",
"",
f"○ [cyan]/clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/plan[/] if used)",
f"○ [cyan]/analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/tasks[/], before [cyan]/implement[/])"
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[/])"
]
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2))
console.print()
console.print(enhancements_panel)
if selected_ai == "codex":
warning_text = """[bold yellow]Important Note:[/bold yellow]
Custom prompts do not yet support arguments in Codex. You may need to manually specify additional project instructions directly in prompt files located in [cyan].codex/prompts/[/cyan].
For more information, see: [cyan]https://github.com/openai/codex/issues/2890[/cyan]"""
warning_panel = Panel(warning_text, title="Slash Commands in Codex", border_style="yellow", padding=(1,2))
console.print()
console.print(warning_panel)
@app.command()
def check():
"""Check that all required tools are installed."""
@@ -1115,7 +1066,7 @@ def check():
console.print("[bold]Checking for installed tools...[/bold]\n")
tracker = StepTracker("Check Available Tools")
tracker.add("git", "Git version control")
tracker.add("claude", "Claude Code CLI")
tracker.add("gemini", "Gemini CLI")
@@ -1130,7 +1081,8 @@ def check():
tracker.add("auggie", "Auggie CLI")
tracker.add("roo", "Roo Code")
tracker.add("codebuddy", "CodeBuddy")
tracker.add("q", "Amazon Q Developer CLI")
git_ok = check_tool_for_tracker("git", tracker)
claude_ok = check_tool_for_tracker("claude", tracker)
gemini_ok = check_tool_for_tracker("gemini", tracker)
@@ -1145,6 +1097,7 @@ def check():
auggie_ok = check_tool_for_tracker("auggie", tracker)
roo_ok = check_tool_for_tracker("roo", tracker)
codebuddy_ok = check_tool_for_tracker("codebuddy", tracker)
q_ok = check_tool_for_tracker("q", tracker)
console.print(tracker.render())
@@ -1152,13 +1105,12 @@ def check():
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 windsurf_ok or kilocode_ok or opencode_ok or codex_ok or auggie_ok or roo_ok or codebuddy_ok):
console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]")
if not (claude_ok or gemini_ok or cursor_ok or qwen_ok or windsurf_ok or kilocode_ok or opencode_ok or codex_ok or auggie_ok or codebuddy_ok or q_ok):
console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]")
def main():
app()
if __name__ == "__main__":
main()