diff --git a/src/gemstack/adapters/base.py b/src/gemstack/adapters/base.py index 359bf47..9994d79 100644 --- a/src/gemstack/adapters/base.py +++ b/src/gemstack/adapters/base.py @@ -21,7 +21,38 @@ class InstallResult: @runtime_checkable class AgentAdapter(Protocol): - """Protocol that all installable agent adapters must implement.""" + """Protocol that all installable agent adapters must implement. + + Adapters are responsible for installing/uninstalling Gemstack + workflow commands into a specific AI agent tool (e.g., Gemini CLI, + Cursor, Claude Desktop). + + Example — a minimal adapter for a custom agent:: + + class MyAgentAdapter: + @property + def name(self) -> str: + return "My Agent" + + @property + def is_available(self) -> bool: + return shutil.which("myagent") is not None + + def install(self, data_dir: Path, copy_mode: bool = False) -> InstallResult: + target = Path.home() / ".myagent" / "workflows" + target.mkdir(parents=True, exist_ok=True) + count = 0 + for md_file in data_dir.glob("*.md"): + (target / md_file.name).write_text(md_file.read_text()) + count += 1 + return InstallResult(success=True, installed_count=count, skipped_count=0) + + def uninstall(self) -> InstallResult: + return InstallResult(success=True, installed_count=0, skipped_count=0) + + def verify(self) -> list[str]: + return [] + """ @property def name(self) -> str: diff --git a/src/gemstack/ai/bootstrap.py b/src/gemstack/ai/bootstrap.py index a9cfd5d..aad9b5a 100644 --- a/src/gemstack/ai/bootstrap.py +++ b/src/gemstack/ai/bootstrap.py @@ -79,28 +79,75 @@ class AIBootstrapper: """ def __init__(self, model: str = "gemini-3.1-pro-preview") -> None: + import shutil + + self.has_cli = shutil.which("gemini") is not None + self.model = model + self.client = None + try: from google import genai - except ImportError: - raise ImportError( - "The 'ai' extra is required for AI bootstrapping. " - "Install with: pip install gemstack[ai]" - ) from None - from gemstack.project.config import GemstackConfig - - config = GemstackConfig.load() - api_key = config.get_api_key() - - self._genai = genai - if api_key: - self.client = genai.Client(api_key=api_key) - else: - # Fall back to env vars (which the SDK checks automatically) - self.client = genai.Client() - self.model = model + from gemstack.project.config import GemstackConfig + + config = GemstackConfig.load() + api_key = config.get_api_key() + + self._genai = genai + if api_key: + self.client = genai.Client(api_key=api_key) + else: + self.client = genai.Client() + except ImportError: + if not self.has_cli: + raise ImportError( + "The 'ai' extra or Gemini CLI is required for AI bootstrapping. " + "Install SDK with: pip install gemstack[ai] or install CLI." + ) from None async def analyze(self, profile: ProjectProfile) -> BootstrapResult: + """Analyze the codebase, preferring Gemini CLI over SDK.""" + if self.has_cli: + try: + return await self._analyze_with_cli(profile) + except Exception as e: + logger.warning(f"CLI analysis failed: {e}. Falling back to SDK if available.") + if not self.client: + raise + + return await self._analyze_with_sdk(profile) + + async def _analyze_with_cli(self, profile: ProjectProfile) -> BootstrapResult: + """Send key source files to Gemini CLI via subprocess.""" + context_parts = self._build_context(profile) + system_instruction = self._load_system_instruction() + + payload = "\n\n".join(context_parts) + + logger.info("Executing Gemini CLI for AI analysis...") + process = await asyncio.create_subprocess_exec( + "gemini", + system_instruction, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + stdout, stderr = await process.communicate(input=payload.encode("utf-8")) + + if process.returncode != 0: + error_msg = stderr.decode("utf-8").strip() + raise RuntimeError( + f"Gemini CLI failed with exit code {process.returncode}: {error_msg}" + ) + + class DummyResponse: + def __init__(self, text: str): + self.text = text + + return self._parse_response(DummyResponse(stdout.decode("utf-8"))) + + async def _analyze_with_sdk(self, profile: ProjectProfile) -> BootstrapResult: """Send key source files to Gemini for deep analysis. Strategy: @@ -119,9 +166,12 @@ async def analyze(self, profile: ProjectProfile) -> BootstrapResult: system_instruction = self._load_system_instruction() # Call Gemini (run sync SDK in thread to avoid blocking) - try: - response = await asyncio.to_thread( - self.client.models.generate_content, + max_retries = 3 + base_delay = 10 + + def _call_api() -> object: + assert self.client is not None, "Gemini SDK client not initialized" + return self.client.models.generate_content( model=self.model, contents=context_parts, config={ @@ -130,9 +180,31 @@ async def analyze(self, profile: ProjectProfile) -> BootstrapResult: "max_output_tokens": 8192, }, ) - except Exception as e: - logger.error(f"Gemini API call failed: {e}") - raise + + for attempt in range(max_retries): + try: + response = await asyncio.to_thread(_call_api) + break + except Exception as e: + error_str = str(e).lower() + is_rate_limit = "429" in error_str or "resource_exhausted" in error_str + if is_rate_limit and attempt < max_retries - 1: + import re + + delay = base_delay * (2 ** attempt) + match = re.search(r"'retryDelay':\s*'(\d+)s'", error_str, re.IGNORECASE) + if match: + delay = int(match.group(1)) + 1 # Add 1s buffer + + logger.warning( + f"Rate limit hit. Retrying in {delay}s... " + f"(Attempt {attempt + 1}/{max_retries})" + ) + await asyncio.sleep(delay) + continue + + logger.error(f"Gemini API call failed: {e}") + raise return self._parse_response(response) diff --git a/src/gemstack/cli/compile_cmd.py b/src/gemstack/cli/compile_cmd.py index 2066a5b..9ac4e1e 100644 --- a/src/gemstack/cli/compile_cmd.py +++ b/src/gemstack/cli/compile_cmd.py @@ -94,4 +94,8 @@ def _copy_to_clipboard(text: str) -> None: elif sys.platform == "win32": subprocess.run(["clip"], input=text.encode(), check=True) else: - console.print("[yellow]⚠️ Clipboard not supported on this platform[/yellow]") + console.print( + "[yellow]⚠️ Clipboard not supported on this platform. " + "Try installing `xclip` or use the `--out ` flag " + "to save to a file instead.[/yellow]" + ) diff --git a/src/gemstack/cli/diagnose_cmd.py b/src/gemstack/cli/diagnose_cmd.py new file mode 100644 index 0000000..401f3e7 --- /dev/null +++ b/src/gemstack/cli/diagnose_cmd.py @@ -0,0 +1,180 @@ +"""gemstack diagnose — Unified diagnostic dashboard. + +Aggregates ``doctor`` (environment), ``check`` (.agent/ integrity), +and ``diff`` (context drift) into a single report so users don't need +to remember which diagnostic command to run. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer +from rich.panel import Panel +from rich.table import Table + +from gemstack.cli.context import console + + +def diagnose( + project_root: Annotated[ + Path, typer.Argument(help="Project root directory", resolve_path=True) + ] = Path("."), +) -> None: + """Run all diagnostics (doctor + check + diff) in a single dashboard.""" + console.print( + Panel( + "[bold cyan]Running unified diagnostics...[/bold cyan]", + title="🩺 gemstack diagnose", + border_style="cyan", + ) + ) + + issues: list[tuple[str, str, str]] = [] # (category, severity, message) + + # ── Section 1: Environment (doctor) ───────────────────────── + issues.extend(_run_doctor_checks()) + + # ── Section 2: .agent/ Integrity (check) ──────────────────── + issues.extend(_run_check_validation(project_root)) + + # ── Section 3: Context Drift (diff) ───────────────────────── + issues.extend(_run_diff_analysis(project_root)) + + # ── Render unified table ──────────────────────────────────── + if not issues: + console.print( + Panel( + "[bold green]All diagnostics passed![/bold green]\n" + "[dim]Environment, .agent/ integrity, and context drift — all clear.[/dim]", + title="✅ System Health", + border_style="green", + ) + ) + return + + table = Table(title="Diagnostic Results", show_lines=True) + table.add_column("Category", style="bold", width=14) + table.add_column("Severity", justify="center", width=8) + table.add_column("Issue") + + has_errors = False + for category, severity, message in issues: + sev_style = "[red]ERROR[/red]" if severity == "ERROR" else "[yellow]WARN[/yellow]" + if severity == "ERROR": + has_errors = True + table.add_row(category, sev_style, message) + + console.print(table) + + if has_errors: + console.print( + "\n[dim]Fix the errors above, then re-run " + "`gemstack diagnose` to verify.[/dim]" + ) + raise typer.Exit(code=1) + + +def _run_doctor_checks() -> list[tuple[str, str, str]]: + """Run environment checks (subset of ``gemstack doctor``).""" + import platform + + issues: list[tuple[str, str, str]] = [] + + # Antigravity directory + antigravity_dir = Path.home() / ".gemini" / "antigravity" + if not antigravity_dir.exists(): + issues.append(("Environment", "WARN", "Antigravity directory not found")) + + # Global workflows + workflows_dir = antigravity_dir / "global_workflows" + if not workflows_dir.exists(): + issues.append(("Environment", "WARN", "Global workflows directory not found")) + + # Bundled data + try: + from importlib.resources import files + + files("gemstack.data") + except Exception: + issues.append(("Environment", "ERROR", "Bundled data not accessible")) + + # Platform info (informational — check for known issues) + if platform.system() == "Windows" and not _is_wsl(): + issues.append( + ("Environment", "WARN", "Native Windows detected — WSL is recommended") + ) + + return issues + + +def _run_check_validation(project_root: Path) -> list[tuple[str, str, str]]: + """Run .agent/ validation (subset of ``gemstack check``).""" + issues: list[tuple[str, str, str]] = [] + + agent_dir = project_root / ".agent" + if not agent_dir.exists(): + issues.append( + ( + "Integrity", + "ERROR", + "No .agent/ directory found — run `gemstack init`", + ) + ) + return issues + + try: + from gemstack.project.validator import ProjectValidator + + validator = ProjectValidator() + result = validator.validate(project_root, auto_fix=False) + + for err in result.errors: + issues.append(("Integrity", "ERROR", err)) + for warn in result.warnings: + issues.append(("Integrity", "WARN", warn)) + except Exception as e: + issues.append(("Integrity", "ERROR", f"Validation failed: {e}")) + + return issues + + +def _run_diff_analysis(project_root: Path) -> list[tuple[str, str, str]]: + """Run context drift detection (subset of ``gemstack diff``).""" + issues: list[tuple[str, str, str]] = [] + + agent_dir = project_root / ".agent" + if not agent_dir.exists(): + # Already reported by check — skip silently + return issues + + try: + from gemstack.utils.differ import ContextDiffer + + differ = ContextDiffer() + report = differ.analyze(project_root) + + if report.has_drift: + for dep in report.new_dependencies: + issues.append(("Drift", "WARN", f"New dependency: {dep}")) + for dep in report.removed_dependencies: + issues.append(("Drift", "WARN", f"Removed dependency: {dep}")) + for var in report.new_env_vars: + issues.append(("Drift", "WARN", f"New env var: {var}")) + for var in report.removed_env_vars: + issues.append(("Drift", "WARN", f"Removed env var: {var}")) + for ref in report.stale_file_refs: + issues.append(("Drift", "WARN", f"Stale file ref: {ref}")) + except Exception as e: + issues.append(("Drift", "WARN", f"Drift analysis failed: {e}")) + + return issues + + +def _is_wsl() -> bool: + """Detect if running inside WSL.""" + try: + return "microsoft" in Path("/proc/version").read_text().lower() + except (FileNotFoundError, OSError): + return False diff --git a/src/gemstack/cli/hook_cmd.py b/src/gemstack/cli/hook_cmd.py index df3e7d7..dde2303 100644 --- a/src/gemstack/cli/hook_cmd.py +++ b/src/gemstack/cli/hook_cmd.py @@ -72,7 +72,10 @@ def install( hooks_dir = project_root / ".git" / "hooks" if not hooks_dir.exists(): - console.print("[red]❌ No .git/hooks/ directory found. Are you in a git repository?[/red]") + console.print( + "[red]❌ No .git/hooks/ directory found. " + "Run `git init` to initialize a repository first.[/red]" + ) raise typer.Exit(code=1) # Determine which hooks to install @@ -116,7 +119,10 @@ def uninstall( hooks_dir = project_root / ".git" / "hooks" if not hooks_dir.exists(): - console.print("[yellow]⚠️ No .git/hooks/ directory found.[/yellow]") + console.print( + "[yellow]⚠️ No .git/hooks/ directory found. " + "Run `git init` to initialize a repository first.[/yellow]" + ) return count = 0 diff --git a/src/gemstack/cli/init_cmd.py b/src/gemstack/cli/init_cmd.py index 3bf7162..04f9bbe 100644 --- a/src/gemstack/cli/init_cmd.py +++ b/src/gemstack/cli/init_cmd.py @@ -37,6 +37,12 @@ def init( bool, typer.Option("--from-legacy", help="Absorb existing context files only") ] = False, ai: Annotated[bool, typer.Option("--ai", help="Force AI-powered deep analysis")] = False, + force: Annotated[ + bool, + typer.Option( + "--force", "-f", help="Overwrite existing .agent/ directory without prompting" + ), + ] = False, ) -> None: """Initialize a Gemstack project with .agent/ directory.""" console.print( @@ -50,9 +56,19 @@ def init( agent_dir = project_root / ".agent" - if agent_dir.exists(): - console.print("[yellow]⚠️ .agent/ directory already exists. Skipping.[/yellow]") - raise typer.Exit(0) + if agent_dir.exists() and not force: + overwrite = typer.confirm( + "⚠️ .agent/ directory already exists. " + "Do you want to overwrite it? (Use --force to skip this prompt)", + default=False, + ) + if not overwrite: + console.print("[yellow]Skipping initialization.[/yellow]") + console.print( + "[dim]Tip: Use `--force` to bypass this prompt " + "and overwrite automatically.[/dim]" + ) + raise typer.Exit(0) # Step 1: Detect project profile console.print("[dim]Analyzing project...[/dim]") @@ -73,13 +89,14 @@ def init( # Legacy files will be available as context for AI analysis # Step 3: Generate .agent/ files - use_ai = ai or (not no_ai and _has_api_key()) + use_ai = ai or (not no_ai and (_has_gemini_cli() or _has_api_key())) if use_ai: - if not _has_api_key(): + if not _has_gemini_cli() and not _has_api_key(): console.print( - "[red]❌ AI analysis failed: No API key was provided.[/red]\n" - " [dim]Configure your API key by running:[/dim]\n" + "[red]❌ AI analysis failed: Gemini CLI not found and no API key provided.[/red]\n" + " [dim]Install the Gemini CLI (npm install -g @google/generative-ai-cli) " + "OR configure an API key:[/dim]\n" " [bold cyan]gemstack config set gemini-api-key [/bold cyan]" ) console.print("\n[dim]Falling back to template-only mode...[/dim]") @@ -131,6 +148,13 @@ def _has_api_key() -> bool: return config.get_api_key() is not None +def _has_gemini_cli() -> bool: + """Check if the Gemini CLI is installed and available in PATH.""" + import shutil + + return shutil.which("gemini") is not None + + def _init_template_only(project_root: Path, profile: ProjectProfile) -> None: """Initialize using Jinja2 templates only (no AI).""" from gemstack.project.templates import render_agent_files diff --git a/src/gemstack/cli/install_cmd.py b/src/gemstack/cli/install_cmd.py index 3d9393e..1312bad 100644 --- a/src/gemstack/cli/install_cmd.py +++ b/src/gemstack/cli/install_cmd.py @@ -107,8 +107,8 @@ def _install_gemini_cli() -> None: if not commands_dir.parent.exists(): console.print( - " [yellow]⚠️ Gemini CLI directory not found. " - "Skipping. Install Gemini CLI first.[/yellow]" + " [yellow]⚠️ Gemini CLI directory not found. Skipping. " + "Install it globally with `npm install -g @google/generative-ai-cli` first.[/yellow]" ) return diff --git a/src/gemstack/cli/json_log_formatter.py b/src/gemstack/cli/json_log_formatter.py new file mode 100644 index 0000000..92903de --- /dev/null +++ b/src/gemstack/cli/json_log_formatter.py @@ -0,0 +1,47 @@ +"""JSON log formatter for CI/CD environments. + +Emits structured JSON lines to stderr, replacing Rich's human-readable +output when ``--json-logs`` is passed to the root CLI command. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone + + +class JsonLogFormatter(logging.Formatter): + """Emit log records as single-line JSON objects. + + Output schema per line:: + + { + "timestamp": "2026-04-24T17:30:00.000Z", + "level": "INFO", + "logger": "gemstack.orchestration.executor", + "message": "Acquired lock: .agent/.gemstack.lock" + } + + Exception info, if present, is appended to the ``message`` field + so every record is a single parseable JSON line. + """ + + def format(self, record: logging.LogRecord) -> str: + """Format a log record as a JSON line.""" + message = record.getMessage() + + # Append exception info to message if present + if record.exc_info and record.exc_info[0] is not None: + exc_text = self.formatException(record.exc_info) + message = f"{message}\n{exc_text}" + + entry = { + "timestamp": datetime.fromtimestamp( + record.created, tz=timezone.utc + ).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": message, + } + return json.dumps(entry, ensure_ascii=False) diff --git a/src/gemstack/cli/main.py b/src/gemstack/cli/main.py index 17db8a1..223a2c5 100644 --- a/src/gemstack/cli/main.py +++ b/src/gemstack/cli/main.py @@ -1,7 +1,11 @@ """Main CLI entry point for Gemstack.""" import logging +import os +import platform import sys +import traceback +from datetime import datetime, timezone from pathlib import Path from typing import Annotated @@ -20,14 +24,89 @@ def _gemstack_excepthook(exc_type, exc_value, exc_traceback): # type: ignore - """Global exception handler for GemstackErrors.""" + """Global exception handler for GemstackErrors. + + Known GemstackErrors are displayed as structured panels. + Unexpected exceptions generate a crash dump file with full + debugging context so issues can be diagnosed without local + reproduction. + """ if issubclass(exc_type, GemstackError): handle_error(exc_value) else: - # Fall back to default exception formatting for pure bugs + # Generate crash dump before falling back to default output + crash_path = _write_crash_dump(exc_type, exc_value, exc_traceback) + if crash_path: + err_console.print( + f"\n[bold red]Unexpected error — crash report saved to:[/bold red]\n" + f" [dim]{crash_path}[/dim]\n" + f"[dim]Please include this file when reporting a bug.[/dim]\n" + ) sys.__excepthook__(exc_type, exc_value, exc_traceback) +def _write_crash_dump( + exc_type: type[BaseException], + exc_value: BaseException, + exc_traceback: object, +) -> Path | None: + """Write a structured crash dump for post-mortem debugging. + + The dump includes the full traceback, Python/OS/gemstack versions, + the .agent/ directory listing, and environment variable keys + (values are redacted for security). + """ + try: + from platformdirs import user_config_dir + + from gemstack import __version__ + + crashes_dir = Path(user_config_dir("gemstack")) / "crashes" + crashes_dir.mkdir(parents=True, exist_ok=True) + + ts = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ") + crash_path = crashes_dir / f"gemstack-crash-{ts}.log" + + tb_text = "".join( + traceback.format_exception(exc_value) + ) + + # Collect .agent/ file listing from cwd + agent_dir = Path.cwd() / ".agent" + if agent_dir.exists(): + agent_listing = "\n".join( + f" {f.name} ({f.stat().st_size} bytes)" + for f in sorted(agent_dir.iterdir()) + if f.is_file() + ) + else: + agent_listing = " (no .agent/ directory in cwd)" + + # Redacted environment keys + env_keys = ", ".join(sorted(os.environ.keys())) + + report = ( + f"=== Gemstack Crash Report ===" + f"\nTimestamp: {ts}" + f"\nGemstack Version: {__version__}" + f"\nPython: {sys.version}" + f"\nPlatform: {platform.system()} {platform.machine()}" + f"\nCWD: {Path.cwd()}" + f"\n\n--- .agent/ Directory ---" + f"\n{agent_listing}" + f"\n\n--- Environment Keys (values redacted) ---" + f"\n{env_keys}" + f"\n\n--- Traceback ---" + f"\n{tb_text}" + ) + + crash_path.write_text(report, encoding="utf-8") + return crash_path + except Exception: + # Never let crash-dump logic itself crash the process + return None + + sys.excepthook = _gemstack_excepthook @@ -90,6 +169,13 @@ def main( ] = Path("."), verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False, debug: Annotated[bool, typer.Option("--debug", help="Enable debug logging")] = False, + json_logs: Annotated[ + bool, + typer.Option( + "--json-logs", + help="Emit structured JSON logs to stderr (for CI/CD pipelines)", + ), + ] = False, version: Annotated[ bool | None, typer.Option( @@ -111,20 +197,27 @@ def main( log_level = logging.DEBUG if debug else (logging.INFO if verbose else logging.WARNING) - from rich.logging import RichHandler - - logging.basicConfig( - level=log_level, - format="%(levelname)s %(name)s: %(message)s", - handlers=[ - RichHandler( - level=log_level, - console=err_console, - show_path=False, - markup=True, - ) - ], - ) + if json_logs: + from gemstack.cli.json_log_formatter import JsonLogFormatter + + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(JsonLogFormatter()) + logging.basicConfig(level=log_level, handlers=[handler]) + else: + from rich.logging import RichHandler + + logging.basicConfig( + level=log_level, + format="%(levelname)s %(name)s: %(message)s", + handlers=[ + RichHandler( + level=log_level, + console=err_console, + show_path=False, + markup=True, + ) + ], + ) # --- Rich help panel names for command grouping --- @@ -179,6 +272,7 @@ def _register_commands() -> None: # ── Context & Analysis ────────────────────────────────── from gemstack.cli.compare_cmd import compare from gemstack.cli.compile_cmd import compile + from gemstack.cli.diagnose_cmd import diagnose from gemstack.cli.diff_cmd import diff from gemstack.cli.export_cmd import export from gemstack.cli.migrate_cmd import migrate @@ -188,6 +282,7 @@ def _register_commands() -> None: app.command(rich_help_panel=_CONTEXT)(compile) app.command(rich_help_panel=_CONTEXT)(check) app.command(rich_help_panel=_CONTEXT)(diff) + app.command(rich_help_panel=_CONTEXT)(diagnose) app.command(rich_help_panel=_CONTEXT)(migrate) app.command(rich_help_panel=_CONTEXT)(export) app.command(rich_help_panel=_CONTEXT)(snapshot) diff --git a/src/gemstack/cli/matrix_cmd.py b/src/gemstack/cli/matrix_cmd.py index ed10767..1ade4cd 100644 --- a/src/gemstack/cli/matrix_cmd.py +++ b/src/gemstack/cli/matrix_cmd.py @@ -34,7 +34,11 @@ def matrix( raise typer.Exit(code=1) if not project_paths: - console.print("[yellow]⚠️ No Gemstack projects found.[/yellow]") + console.print( + "[yellow]⚠️ No Gemstack projects found. " + "Run `gemstack registry add ` or `gemstack registry scan ` " + "to register projects first.[/yellow]" + ) raise typer.Exit(code=1) rows = [_get_project_status(p) for p in project_paths] diff --git a/src/gemstack/cli/phase_cmd.py b/src/gemstack/cli/phase_cmd.py index b1da798..2fe5516 100644 --- a/src/gemstack/cli/phase_cmd.py +++ b/src/gemstack/cli/phase_cmd.py @@ -57,7 +57,10 @@ def phase( current_state = current_state_match.group(1) if current_state_match else "UNKNOWN" if current_state == "INITIALIZED" and phase_key != "spec": - console.print("[yellow]⚠️ Project is INITIALIZED. Start with 'spec' phase first.[/yellow]") + console.print( + "[yellow]⚠️ Project is INITIALIZED. " + "Run `gemstack phase spec` to begin your first feature.[/yellow]" + ) # Check for plan doc requirement before build if phase_key == "build": @@ -65,7 +68,7 @@ def phase( if plans_dir.exists() and not any(plans_dir.iterdir()): console.print( "[yellow]⚠️ No plan documents found in docs/plans/. " - "Consider running /step2-trap first.[/yellow]" + "Create a plan or run the `/step2-trap` workflow to generate one.[/yellow]" ) # Update state diff --git a/src/gemstack/cli/prompt_cmd.py b/src/gemstack/cli/prompt_cmd.py index d03d29e..3128f14 100644 --- a/src/gemstack/cli/prompt_cmd.py +++ b/src/gemstack/cli/prompt_cmd.py @@ -38,7 +38,10 @@ def create( prompt_file = prompt_dir / f"{version}.md" if prompt_file.exists(): - console.print(f"[yellow]⚠️ Prompt {name}/{version} already exists.[/yellow]") + console.print( + f"[yellow]⚠️ Prompt {name}/{version} already exists. " + "Use `--force` to overwrite or bump the version.[/yellow]" + ) raise typer.Exit(code=1) now = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") @@ -69,7 +72,10 @@ def bump( prompt_dir = project_root / "prompts" / name if not prompt_dir.exists(): - console.print(f"[red]❌ Prompt '{name}' not found.[/red]") + console.print( + f"[red]❌ Prompt '{name}' not found. " + f"Run `gemstack prompt create {name}` to generate it.[/red]" + ) raise typer.Exit(code=1) # Find current latest version diff --git a/src/gemstack/cli/replay_cmd.py b/src/gemstack/cli/replay_cmd.py index 181b382..827623f 100644 --- a/src/gemstack/cli/replay_cmd.py +++ b/src/gemstack/cli/replay_cmd.py @@ -27,7 +27,10 @@ def replay( archive_dir = project_root / "docs" / "archive" if not archive_dir.exists(): - console.print("[yellow]⚠️ No docs/archive/ directory found.[/yellow]") + console.print( + "[yellow]⚠️ No docs/archive/ directory found. " + "Archive a feature first by completing the `gemstack phase ship` process.[/yellow]" + ) raise typer.Exit(code=1) if all_features or not feature: @@ -41,7 +44,10 @@ def _replay_all(archive_dir: Path, project_root: Path) -> None: features = sorted(d for d in archive_dir.iterdir() if d.is_dir()) if not features: - console.print("[yellow]⚠️ No archived features found.[/yellow]") + console.print( + "[yellow]⚠️ No archived features found. " + "Complete and ship a feature using `gemstack phase ship` before replaying.[/yellow]" + ) return table = Table(title="Feature Archive", show_header=True) diff --git a/src/gemstack/cli/start_cmd.py b/src/gemstack/cli/start_cmd.py index 88ed55d..3932f93 100644 --- a/src/gemstack/cli/start_cmd.py +++ b/src/gemstack/cli/start_cmd.py @@ -72,6 +72,9 @@ def start( except subprocess.CalledProcessError as e: console.print(f"[yellow]⚠️ Failed to create branch: {e.stderr.strip()}[/yellow]") except FileNotFoundError: - console.print("[yellow]⚠️ git not found — skipping branch creation[/yellow]") + console.print( + "[yellow]⚠️ git not found — skipping branch creation. " + "Install `git` and ensure it is in your PATH.[/yellow]" + ) console.print("[dim]Next: Run `gemstack route` to determine your next step.[/dim]") diff --git a/src/gemstack/data/context/context_prompt.md b/src/gemstack/data/context/context_prompt.md new file mode 100644 index 0000000..e6c4a67 --- /dev/null +++ b/src/gemstack/data/context/context_prompt.md @@ -0,0 +1,218 @@ +# Agent Context Bootstrapping Instructions + +You are an expert Software Architect and Tech Lead. Your objective is to bootstrap the `.agent/` context directory for this repository. + +The `.agent/` directory acts as the permanent memory and rules engine for all future AI agents operating in this codebase. By establishing strict architectural boundaries, style rules, and testing strategies, you ensure that agents do not drift, hallucinate styles, or break conventions as the project scales. + +## Your Task + +The user has placed template files in the `.agent/` directory of this project. Your job is to systematically analyze the existing codebase, read the templates, and **overwrite the templates with highly specific, concrete details extracted from the project.** + +--- + +## Phase -1: New vs Existing Project Detection + +Before doing anything else, determine whether this is a **new project** (empty or near-empty repo) or an **existing project** with code already present. + +### How to detect: +- If `package.json`, `go.mod`, `pyproject.toml`, `Cargo.toml`, `Makefile`, `docker-compose.yml`, OR any `src/`, `cmd/`, `internal/`, `lib/` source directories exist → **Existing project**. Proceed to Phase 0. +- If the repo is empty or contains only a README and/or license → **New project**. Continue below. + +### New Project Setup: +If this is a brand-new project with no code yet: + +1. **Generate `.gitignore`** for the planned tech stack. At minimum include: + ``` + node_modules/ + .env + .env.local + .venv/ + __pycache__/ + .DS_Store + dist/ + build/ + .next/ + *.pyc + ``` + Adapt to the specific stack (add `.vercel/`, `coverage/`, `bin/`, `vendor/`, `tmp/` as relevant). + This MUST happen before the first commit to prevent secrets and build artifacts from entering git history. + +2. **Create `.env.example`** with initial placeholder variables: + ``` + # Copy this file to .env and fill in the values + # DATABASE_URL=postgresql://user:password@localhost:5432/dbname + # (add environment variables as features require them) + ``` + +3. **Initialize git** if not already done: `git init` + +4. After completing the rest of the bootstrapping process, create an initial commit with the scaffold. + +5. Set STATUS.md "Current Focus" to: "Project just bootstrapped. Ready for first feature ideation." + +Then proceed to Phase 1 (skip Phase 0 and 0.5 — there is no legacy content to ingest). + +--- + +## Phase 0: Legacy Context Ingestion + +Before analyzing the codebase from scratch, check if existing AI agent context files already exist. These contain valuable rules that should be absorbed into the standardized `.agent/` structure: + +1. **Check for legacy files**: Scan the repo root for any of these: + - `.cursorrules` — Cursor/Copilot rules + - `GEMINI.md` — Gemini CLI context + - `AGENTS.md` — Generic agent instructions + - `CLAUDE.md` — Claude Code context + - `CONTRIBUTING.md` — Contributing guidelines (often contain code standards) + - `DESIGN.md` / `design.md` — Design system or architecture documentation + - Any existing `.agent/` files with project-specific content (NOT the templates you just placed) +2. **Absorb, don't discard**: Read each legacy file. Extract the rules, constraints, and patterns they define. Weave them into the appropriate `.agent/` template (e.g., DESIGN.md rules → STYLE.md, architectural boundaries → ARCHITECTURE.md invariants, code quality rules → STYLE.md anti-patterns). +3. **Deduplicate**: If the same rule appears in multiple legacy files, include it once in the most appropriate `.agent/` file. + +--- + +## Phase 0.5: Pre-existing Content Migration + +If this project already has content in `.agent/` or `docs/` from before the Gemstack bootstrapping, handle it as follows: + +### Existing `.agent/` Directory +If the project already has an `.agent/` directory with **project-specific content** (not the templates you are about to populate): +1. Read and absorb ALL rules, patterns, and constraints from the existing files into your analysis notes. +2. **Delete the existing `.agent/` files** after reading them — they will be fully replaced by the new standardized versions you are creating. +3. **Exception**: Do NOT delete `.agent/workflows/` if it exists — these are project-specific slash commands that live alongside the context files. + +### Existing `docs/` Directory +If the project already has a `docs/` directory with files: +1. **Identify the type of content** in the existing `docs/`: + - **Agent/feature documentation** (design docs, architecture analyses, exploration notes, feature specs, strategy docs, implementation plans): + → **Move** these files to `docs/archive/pre-gemstack/`. Create this directory if needed. + → This preserves the content as historical reference while making room for the standardized lifecycle structure. + - **Auto-generated content** (Swagger/OpenAPI specs like `swagger.json`/`docs.go`, JSDoc output, godoc pages, images, API reference): + → **Leave in place**. These are build artifacts or assets, not agent documentation. + → Create the Gemstack subdirectories (`docs/explorations/`, `docs/designs/`, etc.) alongside them. +2. **When in doubt**: If a file could be either, err on the side of moving it to `docs/archive/pre-gemstack/`. It's safer to archive than to accidentally overwrite. + +--- + +## Phase 1: Deep Codebase Analysis + +Before writing any files, you MUST use your search and read tools to investigate the target project: + +### For All Projects: +1. **Tech Stack & Configs**: Read `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, or equivalent to identify the exact language, runtime, framework versions, and dependencies. +2. **Build & Dev Tooling**: Read `vite.config.ts`, `next.config.js`, `tsconfig.json`, `tailwind.config.js`, `Dockerfile`, `docker-compose.yml`, `Makefile`, `.golangci.yml`, `sqlc.yaml`, or equivalent. Understand how the project is built, tested, and run. +3. **Architecture & Routing**: Examine the structure of `src/app`, `src/pages`, `server/routes`, `cmd/`, `internal/handler/`, or backend routing directories. Understand how requests flow. +4. **Database & State**: Review ORM schemas (e.g., `prisma/schema.prisma`, `src/db/schema.ts`, `models/*.py`, `migrations/`, `queries/query.sql`, Drizzle migrations). Identify primary entities, relationships, and critical constraints. Check for virtual tables, search indexes, hypertables, continuous aggregates, or vector stores. +5. **Error Handling**: Search for centralized error patterns (`AppError`, `asyncHandler`, error middleware, `if err != nil` patterns, custom error types, try/catch wrappers). +6. **Environment Variables**: Read `.env.example` (if it exists) to understand required configuration. Catalog all variables. + +### For Frontend / Full-Stack Projects: +7. **Styling & UI**: Analyze core UI components (e.g., `src/components/ui/`). Understand color token usage, class naming conventions, and layout rules. Identify if a component library (e.g., Shadcn, Radix, Material UI) is used. +8. **State Management**: Identify the data fetching pattern (React Query, SWR, Apollo, Redux, Zustand, native fetch) and client state strategy. + +### For Backend / CLI / Pipeline Projects: +9. **Concurrency Model**: Identify threading, async patterns, queue bounds, worker pools, goroutines, channels, mutexes, or event-loop constraints. +10. **Safety Invariants**: Search for "NEVER", "MUST", "CRITICAL" comments in the codebase — these reveal load-bearing constraints. + +### For Go Projects: +11. **Project Layout**: Check for `cmd/`, `internal/`, `pkg/` standard Go project layout. Identify the binary entrypoints in `cmd/`. +12. **Build System**: Read `Makefile` targets — these are the primary dev interface (build, test, lint, setup, docker-up/down). +13. **Code Generation**: Check for `sqlc.yaml` (SQL-to-Go), `protobuf` definitions, `go generate` directives, or Swagger generation configs (`.swaggo`, `swag init`). +14. **Concurrency Patterns**: Look for goroutine usage, `sync.Mutex`, `sync.WaitGroup`, `context.Context` propagation, advisory locks, and channel patterns. + +### For All Projects: +15. **Testing**: Check testing configs (`vitest.config.ts`, `playwright.config.ts`, `jest.config.js`, `pytest.ini`, `pyproject.toml [tool.pytest]`, `Makefile test target`) and read a few existing test files to determine testing paradigms, commands, and coverage expectations. +16. **AI & External Integrations**: Look for AI SDKs, third-party API clients, adapter patterns, rate limiters, OAuth2 flows, and caching layers. + +### Topology Detection: +17. **Determine Project Topology**: Based on your analysis, identify which topology attributes apply: + - **`backend`**: Has server-side code, API routes, database operations, or CLI entrypoints. Also applies to the backend portion of a full-stack project + - **`frontend`**: Has web UI components (React, Vue, Svelte), CSS/styling, client-side routing + - **`ml-ai`**: Uses LLM APIs (Gemini, OpenAI, Claude SDKs), ML frameworks (PyTorch, TensorFlow, scikit-learn), or has any probabilistic/non-deterministic outputs + - **`infrastructure`**: Primarily Docker Compose, Terraform, Kubernetes manifests, CI/CD definitions + - **`library-sdk`**: Consumed as a dependency by other projects (Go modules, npm packages, Python libraries) + - **`[none]`**: Documentation repos, simple scripts, or projects that don't fit any category + + A project can have multiple topology attributes. A Next.js app with a Prisma backend is `[frontend, backend]`. A Go API with a RAG pipeline is `[backend, ml-ai]`. + +--- + +## Phase 2: Template Population & Overwrite + +Now, read the template files currently located in the `.agent/` directory. Use their exact structure and headers as your baseline, but **replace the placeholder text with the concrete facts you discovered during your analysis (Phases 0 through 1).** Write the finalized content back to the same files, overwriting the templates. + +### 1. `.agent/ARCHITECTURE.md` +- **Goal**: The definitive anchor for system design. +- **Instructions**: At the top of the file, set the `## 0. Project Topology` section with the detected topology attributes. Detail the exact tech stack with pinned versions. Map out the data flow (e.g., "Client Components → React Query → Express → Drizzle → SQLite" or "CLI → asyncio.TaskGroup → bounded queues → GPU worker" or "HTTP → go-chi middleware → handler → service → sqlc → PostgreSQL"). Document all core database entities and their relational rules (cascading deletes, virtual tables, hypertables, manual cleanup requirements). Define API contracts (methods, paths, request/response shapes) for primary endpoints. For SDK libraries, document the exported public API surface and versioning guarantees. Note any AI providers, external APIs, or complex integrations. If the ML/AI topology is active, populate the Model Ledger in Section 5.1 with all models identified during analysis. Document the concurrency/threading model (goroutines, asyncio, Node.js event loop). List all environment variables from `.env.example`. Include exact local development commands. Document invariants and safety rules found in the codebase. + +### 2. `.agent/STYLE.md` +- **Goal**: Enforce visual identity and structural code patterns. +- **Instructions**: Extract the exact design system rules. Define primary colors, surface hierarchy, and spacing tokens. Document concrete component patterns (show exact CSS classes or Tailwind utilities). Define naming conventions (file naming, variable casing, Go export rules, package naming). Define import ordering rules (goimports standard for Go, ESLint for TS). Document docstring/comment standards (godoc, JSDoc, Google-style). +- **CRITICAL**: Formulate explicit "Anti-Patterns (FORBIDDEN)" based on what the codebase avoids (e.g., "NEVER use `useEffect` for data fetching", "NEVER use `border-gray-200` for sectioning", "NEVER write temp files to disk", "NEVER use `Any`/`interface{}` types", "NEVER write raw SQL outside sqlc queries"). + +### 3. `.agent/TESTING.md` +- **Goal**: Track test methods, execution evidence, and local dev setup. +- **Instructions**: Document the exact steps to get the app running locally (prerequisites, install, start, seed, database, code generation). Document the exact CLI commands to run tests, type checking, and linting (including `go test -race`, `golangci-lint`, `shellcheck` where applicable). Keep the "Execution Evidence Rules" from the template intact. Set up the empty scenario tables ready for the first feature. Initialize the empty Regression Scenarios table. Based on the active topologies, retain or mark as "N/A" the conditional sections: Backend Route Coverage Matrix, Frontend Component State Matrix, and ML/AI Evaluation Thresholds. Populate headers and set up empty tables ready for the first feature. + +### 4. `.agent/PHILOSOPHY.md` +- **Goal**: The soul of the product. +- **Instructions**: Read the project's `README.md` and any existing context files. Infer the core pain point the project solves. Define the target user persona. Synthesize 3-5 core beliefs that drive technical and product decisions. Document design/UX principles (applies to CLI, web, SDK, and headless projects). Define explicit anti-goals (What This Is NOT). *If the product purpose is completely ambiguous, stop and ask the user for a 1-paragraph description before writing this file.* + +### 5. `.agent/STATUS.md` +- **Goal**: The single source of truth for progress. +- **Instructions**: Initialize the tracking state. Set "Current Focus" to "Project Bootstrapped". Leave the lifecycle checkboxes unchecked. Leave "Relevant Files" empty. Leave "Review Results" empty. Set "Active Worktrees" to "(none — sequential execution)". Based on the active topologies, retain or mark as "N/A" the conditional sections: Stub Audit Tracker (for projects with both frontend and backend), and Prompt Versioning Changelog (for ML/AI projects). + +--- + +## Phase 2.5: Handling Non-Standard Project Types + +Some projects don't fit the traditional "application" mold. Adapt the templates as follows: + +### Shell Script Repos (e.g., a single Bash utility) +- **ARCHITECTURE.md**: Document the script's execution phases, safety mechanisms (lockfiles, duration checks, dry-run), CLI flags, and external tool dependencies (ffmpeg, ffprobe, Docker). +- **STYLE.md**: Cover shell conventions (`set -euo pipefail`, quoting rules, function naming as `snake_case`, config variables as `UPPER_SNAKE_CASE`, `shellcheck` compliance). +- **TESTING.md**: Document `shellcheck` linting and manual dry-run testing procedures. Most scenarios are manual. +- **PHILOSOPHY.md**: Focus on the tool's purpose and safety guarantees. +- **STATUS.md**: Standard — track features and improvements like any project. + +### Docker Compose / Infrastructure Repos (e.g., NAS stack definitions) +- **ARCHITECTURE.md**: Document the stack topology (service map, port allocations, network bridges), data flow between services, volume mounts, and security boundaries. This IS the architecture. +- **STYLE.md**: Cover YAML formatting conventions, service naming, volume path standards, label conventions. Visual sections are "N/A". +- **TESTING.md**: Document `docker compose config --quiet` for YAML validation, `docker compose up -d` for smoke testing, and Uptime Kuma / health checks. +- **PHILOSOPHY.md**: Focus on infrastructure goals (self-hosted, zero-trust, data sovereignty). +- **STATUS.md**: Standard — track stack additions, service upgrades, security hardening. + +### Documentation-Only Repos (e.g., Proxmox setup guides, runbooks) +- Most technical sections are "N/A — Documentation repository, no application code". +- **PHILOSOPHY.md**: Define the documentation's audience and purpose. +- **STATUS.md**: Track documentation coverage and accuracy. +- **STYLE.md**: Cover Markdown formatting conventions, heading hierarchy, link standards. + +### SDK / Library Repos (e.g., Go API client wrappers) +- **ARCHITECTURE.md**: The "API Contracts" section documents the exported public API surface (types, functions, interfaces). Include versioning strategy and backward compatibility guarantees. +- **STYLE.md**: Heavy focus on naming conventions, godoc standards, and API design consistency. +- **TESTING.md**: Focus on `go test -race ./...`, example tests, and coverage thresholds. +- **PHILOSOPHY.md**: Focus on API ergonomics, zero-dependency philosophy, and consumer-friendliness. + +--- + +## Phase 3: Docs Scaffolding Verification + +Ensure the following directory structure exists in the project root to support the roles/phases workflow. Create them with `.gitkeep` files if they are missing: +- `docs/explorations/.gitkeep` +- `docs/designs/.gitkeep` +- `docs/plans/.gitkeep` +- `docs/archive/.gitkeep` + +If you moved pre-existing docs to `docs/archive/pre-gemstack/` in Phase 0.5, ensure that directory exists and the files were moved correctly. + +--- + +## Execution Rules for the LLM +- **No Hallucinations**: If a project does not have a database, simply write "N/A — No database utilized" in the database section. Do not invent details. +- **Extreme Specificity**: Do not write generic statements like "Uses Tailwind for styling." Write "Uses Tailwind CSS v4 with a strict tokenized surface hierarchy (`bg-surface`, `bg-surface-container`). 1px borders are FORBIDDEN for sectioning." +- **Absorb Legacy Rules**: If `.cursorrules`, `GEMINI.md`, or similar files exist, their rules MUST be reflected in the appropriate `.agent/` file. Do not ignore them. +- **Clean Up After Absorbing**: After absorbing existing `.agent/` files (Phase 0.5), delete the old files. The new standardized files replace them entirely. +- **Do not delete template sections**: If a section from the template is not applicable, keep the header and write "N/A — [Reason]". +- **Respect auto-generated content**: Do NOT move or modify auto-generated files in `docs/` (Swagger, godoc, images). Only move human-written documentation to the archive. +- **Completion**: Once all 5 files are successfully overwritten, the `docs/` folders are verified, and any pre-existing content is properly archived, inform the user that the project is bootstrapped and ready for the `/ideate` phase. \ No newline at end of file diff --git a/src/gemstack/orchestration/executor.py b/src/gemstack/orchestration/executor.py index d09dd17..e499e78 100644 --- a/src/gemstack/orchestration/executor.py +++ b/src/gemstack/orchestration/executor.py @@ -507,7 +507,10 @@ def _acquire_lock(project_root: Path) -> Path | None: """Acquire a per-project lockfile to prevent concurrent execution. Uses exclusively created file lock, making it safe across - all platforms including Windows. + all platforms including Windows. If a stale lockfile is + detected (the owning PID is no longer alive), the lock is + automatically reclaimed so a crashed previous run does not + permanently block the user. Returns: Path of the lock file, or None if locking fails implicitly. @@ -523,6 +526,23 @@ def _acquire_lock(project_root: Path) -> Path | None: logger.debug(f"Acquired lock: {lock_path}") return lock_path except FileExistsError: + # Check if the lock is stale (owning process is dead) + if StepExecutor._is_lock_stale(lock_path): + logger.warning( + f"Reclaiming stale lockfile from dead process: {lock_path}" + ) + with contextlib.suppress(OSError): + lock_path.unlink() + # Retry once after reclaiming + try: + fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + logger.debug(f"Acquired lock after reclaim: {lock_path}") + return lock_path + except (FileExistsError, OSError): + pass # Fall through to the error below + raise RuntimeError( "Another `gemstack run` is already executing in this project. " f"If this is incorrect, remove {lock_path}" @@ -531,6 +551,28 @@ def _acquire_lock(project_root: Path) -> Path | None: logger.warning(f"Failed to acquire lockfile: {e}") return None + @staticmethod + def _is_lock_stale(lock_path: Path) -> bool: + """Check if the PID recorded in a lockfile is still alive. + + Returns True if the lock is stale (process is dead or PID + is unreadable), False if the owning process is still running. + """ + try: + pid_str = lock_path.read_text().strip() + pid = int(pid_str) + except (OSError, ValueError): + # Can't read or parse the PID — treat as stale + return True + + try: + os.kill(pid, 0) # Signal 0: check existence without killing + except ProcessLookupError: + return True # Process does not exist + except PermissionError: + return False # Process exists but we can't signal it + return False # Process is alive + @staticmethod def _release_lock(lock_path: Path | None) -> None: """Release the lockfile.""" diff --git a/src/gemstack/plugins/hooks.py b/src/gemstack/plugins/hooks.py index c5db954..fec8f8a 100644 --- a/src/gemstack/plugins/hooks.py +++ b/src/gemstack/plugins/hooks.py @@ -49,6 +49,30 @@ class GemstackHookSpec: All hooks are optional — plugins only need to implement the hooks they care about. + Example — a minimal plugin that adds a "mobile" topology:: + + # my_gemstack_plugin.py + from gemstack.plugins.hooks import hookimpl + + class MobilePlugin: + @hookimpl + def gemstack_register_topologies(self): + return [{ + "name": "mobile", + "description": "iOS/Android guardrails", + "content": "# Mobile Topology\\n\\n- Test on real devices...", + }] + + @hookimpl + def gemstack_post_init(self, project_root, profile): + mobile_ctx = project_root / ".agent" / "MOBILE.md" + if not mobile_ctx.exists(): + mobile_ctx.write_text("# Mobile Context\\n") + + # In pyproject.toml: + # [project.entry-points."gemstack"] + # mobile = "my_gemstack_plugin:MobilePlugin" + .. versionadded:: 1.0 """ diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 385006c..440a357 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -42,6 +42,7 @@ class TestBootstrapperImport: def test_import_error_without_genai(self) -> None: with ( + patch("shutil.which", return_value=None), patch.dict("sys.modules", {"google": None, "google.genai": None}), pytest.raises(ImportError, match=r"ai.*extra"), ): diff --git a/tests/test_cli.py b/tests/test_cli.py index 873320f..b9ae0c6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -65,10 +65,15 @@ def test_init_creates_agent_dir(self, tmp_path: Path) -> None: assert (tmp_path / ".agent" / "STATUS.md").exists() def test_init_skips_existing(self, bootstrapped_project: Path) -> None: - result = runner.invoke(app, ["init", str(bootstrapped_project)]) + result = runner.invoke(app, ["init", str(bootstrapped_project)], input="n\n") assert result.exit_code == 0 assert "already exists" in result.stdout + def test_init_force_existing(self, bootstrapped_project: Path) -> None: + result = runner.invoke(app, ["init", str(bootstrapped_project), "--no-ai", "--force"]) + assert result.exit_code == 0 + assert "Analyzing project" in result.stdout + def test_init_creates_docs_dirs(self, tmp_path: Path) -> None: runner.invoke(app, ["init", str(tmp_path), "--no-ai"]) assert (tmp_path / "docs" / "explorations").exists()