Merge main into feature/add-qwen-support

Combines Qwen Code support with new script variant structure from main.
Key additions:
- Qwen Code AI assistant support
- PowerShell script variants alongside Bash
- Updated release packaging for per-script variants
- Maintains all existing Cursor support from main

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ahmet Çetinkaya
2025-09-14 13:02:09 +03:00
30 changed files with 902 additions and 804 deletions

View File

@@ -30,7 +30,7 @@ import tempfile
import shutil
import json
from pathlib import Path
from typing import Optional
from typing import Optional, Tuple
import typer
import httpx
@@ -57,8 +57,11 @@ AI_CHOICES = {
"copilot": "GitHub Copilot",
"claude": "Claude Code",
"gemini": "Gemini CLI",
"cursor": "Cursor",
"qwen": "Qwen Code"
}
# 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"
@@ -414,7 +417,7 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> bool:
os.chdir(original_cwd)
def download_template_from_github(ai_assistant: str, download_dir: Path, *, verbose: bool = True, show_progress: bool = True, client: httpx.Client = None):
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:
@@ -426,26 +429,32 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, verb
try:
response = client.get(api_url, timeout=30, follow_redirects=True)
response.raise_for_status()
release_data = response.json()
except httpx.RequestError as e:
if verbose:
console.print(f"[red]Error fetching release information:[/red] {e}")
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
pattern = f"spec-kit-template-{ai_assistant}"
pattern = f"spec-kit-template-{ai_assistant}-{script_type}"
matching_assets = [
asset for asset in release_data.get("assets", [])
if pattern in asset["name"] and asset["name"].endswith(".zip")
]
if not matching_assets:
if verbose:
console.print(f"[red]Error:[/red] No template found for AI assistant '{ai_assistant}'")
console.print(f"[yellow]Available assets:[/yellow]")
for asset in release_data.get("assets", []):
console.print(f" - {asset['name']}")
console.print(f"[red]No matching release asset found[/red] for pattern: [bold]{pattern}[/bold]")
asset_names = [a.get('name','?') for a in release_data.get('assets', [])]
console.print(Panel("\n".join(asset_names) or "(no assets)", title="Available Assets", border_style="yellow"))
raise typer.Exit(1)
# Use the first matching asset
@@ -465,8 +474,10 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, verb
console.print(f"[cyan]Downloading template...[/cyan]")
try:
with client.stream("GET", download_url, timeout=30, follow_redirects=True) as response:
response.raise_for_status()
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:
@@ -489,11 +500,12 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, verb
else:
for chunk in response.iter_bytes(chunk_size=8192):
f.write(chunk)
except httpx.RequestError as e:
if verbose:
console.print(f"[red]Error downloading template:[/red] {e}")
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}")
@@ -506,7 +518,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, verb
return zip_path, metadata
def download_and_extract_template(project_path: Path, ai_assistant: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None) -> Path:
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)
"""
@@ -519,9 +531,11 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, is_curr
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
client=client,
debug=debug
)
if tracker:
tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)")
@@ -638,6 +652,8 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, is_curr
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)
@@ -660,60 +676,44 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, is_curr
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
"""Ensure POSIX .sh scripts in the project .specify/scripts directory have execute bits (no-op on Windows)."""
"""Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows)."""
if os.name == "nt":
return # Windows: skip silently
scripts_dir = project_path / ".specify" / "scripts"
if not scripts_dir.is_dir():
scripts_root = project_path / ".specify" / "scripts"
if not scripts_root.is_dir():
return
failures: list[str] = []
updated = 0
for script in scripts_dir.glob("*.sh"):
for script in scripts_root.rglob("*.sh"):
try:
# Skip symlinks
if script.is_symlink():
if script.is_symlink() or not script.is_file():
continue
# Must be a regular file
if not script.is_file():
continue
# Quick shebang check
try:
with script.open("rb") as f:
first_two = f.read(2)
if first_two != b"#!":
continue
if f.read(2) != b"#!":
continue
except Exception:
continue
st = script.stat()
mode = st.st_mode
# If already any execute bit set, skip
st = script.stat(); mode = st.st_mode
if mode & 0o111:
continue
# Only add execute bits that correspond to existing read bits
new_mode = mode
if mode & 0o400: # owner read
new_mode |= 0o100
if mode & 0o040: # group read
new_mode |= 0o010
if mode & 0o004: # other read
new_mode |= 0o001
# Fallback: ensure at least owner execute
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.name}: {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")
if failures:
tracker.error("chmod", detail)
else:
tracker.complete("chmod", detail)
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)[/cyan]")
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:
@@ -723,18 +723,20 @@ 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)"),
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, or qwen"),
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor, or qwen"),
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, or Qwen Code)
2. Let you choose your AI assistant (Claude Code, Gemini CLI, GitHub Copilot, Cursor, or Qwen Code)
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)
@@ -745,6 +747,7 @@ def init(
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 --ignore-agent-tools my-project
specify init --here --ai claude
@@ -828,13 +831,31 @@ def init(
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
# GitHub Copilot check is not needed as it's typically available in supported IDEs
# 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")
@@ -845,6 +866,8 @@ def init(
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"),
@@ -867,7 +890,7 @@ def init(
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, here, verbose=False, tracker=tracker, client=local_client)
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)
@@ -890,6 +913,16 @@ def init(
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)
@@ -931,6 +964,7 @@ def init(
steps_lines.append(" - Run qwen /tasks to generate tasks")
steps_lines.append(" - See QWEN.md for all available 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")
@@ -955,12 +989,19 @@ def check():
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)")
# 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)
# Render the final tree
console.print(tracker.render())