Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 63 additions & 18 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,35 +24,35 @@
specify init --here
"""

import json
import os
import shlex
import shutil
import ssl
import subprocess
import sys
import zipfile
import tempfile
import shutil
import shlex
import json
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Tuple

import typer
import httpx

# For cross-platform keyboard input
import readchar
import truststore
import typer
from rich.align import Align
from rich.console import Console
from rich.live import Live
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.text import Text
from rich.tree import Tree
from typer.core import TyperGroup

# For cross-platform keyboard input
import readchar
import ssl
import truststore
from datetime import datetime, timezone

ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
client = httpx.Client(verify=ssl_context)

Expand Down Expand Up @@ -228,7 +228,11 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str)
},
}

SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
SCRIPT_TYPE_CHOICES = {
"sh": "POSIX Shell (bash/zsh)",
"fish": "Fish Shell",
"ps": "PowerShell"
}

CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"

Expand Down Expand Up @@ -481,6 +485,41 @@ def run_command(cmd: list[str], check_return: bool = True, capture: bool = False
raise
return None


def detect_shell() -> str:
"""Detect the current shell environment.

Returns:
Shell type identifier: 'fish', 'sh' (for bash/zsh), or 'ps' (PowerShell)
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring states the function returns 'fish', 'sh', or 'ps', but line 503 also checks for "bash" and "zsh" in the shell path. While these do eventually return "sh", the implementation comment suggests these are distinct shells being detected. Consider clarifying in the docstring that bash and zsh are detected and mapped to the "sh" return value for consistency.

Suggested change
Shell type identifier: 'fish', 'sh' (for bash/zsh), or 'ps' (PowerShell)
Shell type identifier:
- 'fish' for the fish shell
- 'sh' for POSIX-style shells (e.g. bash, zsh, sh)
- 'ps' for PowerShell / pwsh

Copilot uses AI. Check for mistakes.
"""
# Check for PowerShell first (Windows)
if os.name == "nt" or "POWERSHELL" in os.environ.get("PSModulePath", "").upper():
return "ps"

# Check SHELL environment variable (Unix-like systems)
shell_path = os.environ.get("SHELL", "")
if "fish" in shell_path:
return "fish"
elif "bash" in shell_path or "zsh" in shell_path or "sh" in shell_path:
Comment on lines +501 to +503
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shell detection logic on line 503 may produce false positives. The check "sh" in shell_path will match many unintended paths like "/usr/bin/bash", "/bin/zsh", or even paths containing "sh" anywhere (e.g., "/home/shelly/fish"). The condition should use .endswith("sh") or a more precise pattern match to avoid incorrectly detecting bash/zsh as generic POSIX shell, and to prevent false positives from paths containing "sh" as a substring.

Suggested change
if "fish" in shell_path:
return "fish"
elif "bash" in shell_path or "zsh" in shell_path or "sh" in shell_path:
shell_name = os.path.basename(shell_path).lower()
if shell_name == "fish":
return "fish"
elif shell_name in ("bash", "zsh", "sh"):

Copilot uses AI. Check for mistakes.
return "sh"

# Fallback: try to detect from process name
try:
import psutil
parent = psutil.Process().parent()
Comment on lines +508 to +509
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The psutil package is imported but not listed in the project dependencies. This will cause an ImportError for users who don't have psutil installed, preventing the fallback shell detection from working properly. Either add psutil to the dependencies in pyproject.toml, or handle the case where the import fails more gracefully by catching the exception at import time rather than during execution.

Copilot uses AI. Check for mistakes.
if parent:
parent_name = parent.name().lower()
if "fish" in parent_name:
return "fish"
elif "pwsh" in parent_name or "powershell" in parent_name:
return "ps"
except ImportError:
pass

# Default fallback based on OS
return "ps" if os.name == "nt" else "sh"


def check_tool(tool: str, tracker: StepTracker = None) -> bool:
"""Check if a tool is installed. Optionally update tracker.

Expand Down Expand Up @@ -946,7 +985,11 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
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-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, bob, or qoder "),
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
script_type: str = typer.Option(
None,
"--script",
help="Script type: sh, fish, or ps (auto-detected if not specified)"
),
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"),
Expand Down Expand Up @@ -1081,7 +1124,8 @@ def init(
raise typer.Exit(1)
selected_script = script_type
else:
default_script = "ps" if os.name == "nt" else "sh"
# Auto-detect shell type based on current environment
default_script = detect_shell()

if sys.stdin.isatty():
selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script)
Expand All @@ -1090,6 +1134,7 @@ def init(

console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}")
console.print(f"[cyan]Selected script type:[/cyan] {selected_script}")
console.print(f"[dim]Detected shell:[/dim] {os.environ.get('SHELL', 'unknown')}")
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The debug output shows the raw SHELL environment variable value rather than the detected shell type. This creates confusion as it displays "Detected shell: /usr/bin/fish" instead of "Detected shell: fish". Consider either changing the label to "Shell path" or displaying the detected shell type (the value of selected_script or default_script) to make this output more helpful for debugging.

Suggested change
console.print(f"[dim]Detected shell:[/dim] {os.environ.get('SHELL', 'unknown')}")
console.print(f"[dim]Detected shell type:[/dim] {selected_script}")

Copilot uses AI. Check for mistakes.

tracker = StepTracker("Initialize Specify Project")

Expand Down Expand Up @@ -1285,8 +1330,8 @@ def check():
@app.command()
def version():
"""Display version and system information."""
import platform
import importlib.metadata
import platform

show_banner()

Expand Down