diff --git a/src/aipass/cli/apps/cli.py b/src/aipass/cli/apps/cli.py index 100782a7..02b997f1 100755 --- a/src/aipass/cli/apps/cli.py +++ b/src/aipass/cli/apps/cli.py @@ -332,6 +332,23 @@ def show_version(): CONSOLE.print("CLI v0.2.0") +def _handle_aipass(args): + """Route aipass subcommands.""" + if not args: + error("Missing subcommand", suggestion="Try: drone @cli aipass init") + sys.exit(1) + + subcmd = args[0] + sub_args = args[1:] + + from aipass.cli.apps.modules.init_project import handle_command + if handle_command(subcmd, sub_args): + return + + error(f"Unknown aipass subcommand: {subcmd}", suggestion="Try: drone @cli aipass init") + sys.exit(1) + + def main(): """CLI branch entry point - shows available services""" @@ -350,8 +367,17 @@ def main(): print_help() return - # Default behavior for other commands - print_help() + # Route subcommands + command = sys.argv[1] + cmd_args = sys.argv[2:] if len(sys.argv) > 2 else [] + + if command == "aipass": + _handle_aipass(cmd_args) + return + + # Unknown command + error(f"Unknown command: {command}", suggestion="Run 'drone @cli --help' for usage") + sys.exit(1) if __name__ == "__main__": diff --git a/src/aipass/cli/apps/handlers/init/__init__.py b/src/aipass/cli/apps/handlers/init/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/cli/apps/handlers/init/bootstrap.py b/src/aipass/cli/apps/handlers/init/bootstrap.py new file mode 100644 index 00000000..3d297839 --- /dev/null +++ b/src/aipass/cli/apps/handlers/init/bootstrap.py @@ -0,0 +1,174 @@ +# =================== AIPass ==================== +# Name: bootstrap.py +# Description: Init handler — bootstrap an AIPass project in any directory +# Version: 1.0.0 +# Created: 2026-03-14 +# Modified: 2026-03-14 +# ============================================= + +""" +Init Bootstrap Handler - PRIVATE implementation + +Business logic for `aipass init`. Creates the project scaffold: + 1. {NAME}_REGISTRY.json — project registry with UUID + 2. .trinity/passport.json — project identity + 3. .trinity/local.json — local context (empty) + 4. .trinity/observations.json — observations (empty) + 5. .aipass/aipass_local_prompt.md — local prompt + 6. AIPASS.md — project prompt + +RULES: + - Pure Python only (no module/prax/cli imports) + - Returns dict, raises exceptions on errors + - No hardcoded paths +""" + +import json +import re +import uuid +from datetime import date +from pathlib import Path + + +def _sanitize_name(raw: str) -> str: + """Sanitize a project name for use in filenames. + + Replaces non-alphanumeric characters (except underscore/hyphen) with + underscores and strips leading/trailing underscores. + """ + return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_") + + +def init_project(target: Path, project_name: str | None = None) -> dict: + """Initialize an AIPass project in the target directory. + + Args: + target: Directory to initialize + project_name: Name for the registry (defaults to directory name) + + Returns: + dict with registry_id, registry_file, project_name, target, created_files + + Raises: + ValueError: If project name is empty after sanitization + FileExistsError: If passport or registry already exists + """ + target = target.resolve() + if not target.exists(): + target.mkdir(parents=True) + + raw_name = project_name or target.name + name = _sanitize_name(raw_name) + if not name: + raise ValueError( + f"Cannot derive project name from '{raw_name}'. " + "Pass a project name explicitly." + ) + + registry_id = str(uuid.uuid4()) + today = date.today().isoformat() + created = [] + + # 1. Registry + registry_filename = f"{name}_REGISTRY.json" + registry_path = target / registry_filename + if registry_path.exists(): + raise FileExistsError(f"Registry already exists: {registry_path}") + + registry_data = { + "metadata": { + "id": registry_id, + "name": name, + "version": "1.0.0", + "created": today, + "last_updated": today, + "total_branches": 0, + }, + "branches": [], + } + registry_path.write_text( + json.dumps(registry_data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + created.append(str(registry_path)) + + # 2. .trinity/ + trinity_dir = target / ".trinity" + trinity_dir.mkdir(exist_ok=True) + + passport = { + "document_metadata": { + "document_type": "project_identity", + "document_name": f"{name}.PASSPORT", + "version": "1.0.0", + "created": today, + "last_updated": today, + }, + "identity": { + "project_name": name, + "role": "project_root", + "purpose": "", + }, + "citizenship": { + "registered": True, + "registry_id": registry_id, + "registry_name": name, + }, + } + passport_path = trinity_dir / "passport.json" + if passport_path.exists(): + raise FileExistsError( + f"Passport already exists: {passport_path}. " + "Remove .trinity/passport.json to re-initialize." + ) + passport_path.write_text( + json.dumps(passport, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + created.append(str(passport_path)) + + # local.json + observations.json + for filename in ("local.json", "observations.json"): + p = trinity_dir / filename + if not p.exists(): + p.write_text("{}\n", encoding="utf-8") + created.append(str(p)) + + # 3. .aipass/ + aipass_dir = target / ".aipass" + aipass_dir.mkdir(exist_ok=True) + + prompt_path = aipass_dir / "aipass_local_prompt.md" + if not prompt_path.exists(): + prompt_path.write_text( + f"# {name} — Local Prompt\n\nInjected every turn. Add project-specific context here.\n", + encoding="utf-8", + ) + created.append(str(prompt_path)) + + # 4. AIPASS.md + aipass_md = target / "AIPASS.md" + if not aipass_md.exists(): + aipass_md.write_text( + "# AIPass — Project Prompt\n\n" + "Project-level instructions. Persists in context for the entire conversation.\n\n" + "## Startup\n\n" + "On any greeting, silently read these files from CWD and run the commands " + "— no narration, no announcing steps. Just do it and respond with the status.\n\n" + "**Read:** `.trinity/passport.json`, `.trinity/local.json`, " + "`.trinity/observations.json`, `README.md`\n" + "**Run:** `git status`\n\n" + "## Memories\n\n" + "Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. " + "If compaction hits before you save, it's gone.\n", + encoding="utf-8", + ) + created.append(str(aipass_md)) + + return { + "registry_id": registry_id, + "registry_file": registry_filename, + "project_name": name, + "target": str(target), + "created_files": created, + } diff --git a/src/aipass/cli/apps/modules/init_project.py b/src/aipass/cli/apps/modules/init_project.py new file mode 100644 index 00000000..28f00838 --- /dev/null +++ b/src/aipass/cli/apps/modules/init_project.py @@ -0,0 +1,165 @@ +# =================== AIPass ==================== +# Name: init_project.py +# Description: Init Project Module — orchestration layer for aipass init +# Version: 1.0.0 +# Created: 2026-03-14 +# Modified: 2026-03-14 +# ============================================= + +""" +Init Project Module - PUBLIC API + +Thin orchestration module for the `aipass init` command. +Routes to the init bootstrap handler for business logic. + +Usage: + drone @cli aipass init [target_dir] [project_name] +""" + +import os +import sys +from pathlib import Path +from typing import List + +from aipass.cli.apps.handlers.init.bootstrap import init_project +from aipass.cli.apps.modules.display import console, success, error, header + + +# ============================================================================= +# MODULE PATTERN FUNCTIONS (SEED compliant) +# ============================================================================= + +def print_introspection(): + """Display module info and connected handlers.""" + console.print() + console.print("[bold cyan]Init Project Module[/bold cyan]") + console.print() + console.print("[dim]Bootstrap an AIPass project in any directory[/dim]") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + console.print(" [cyan]handlers/init/[/cyan]") + console.print(" [dim]- bootstrap.py[/dim]") + console.print() + + console.print("[dim]Run 'drone @cli aipass init --help' for usage[/dim]") + console.print() + + +def print_help(): + """Display help for the init command.""" + console.print() + header("aipass init - Project Bootstrap") + console.print("[dim]Initialize an AIPass project in any directory[/dim]") + console.print() + + console.print("[bold cyan]USAGE:[/bold cyan]") + console.print() + console.print(" [dim]drone @cli aipass init[/dim] Initialize in current directory") + console.print(" [dim]drone @cli aipass init /path/to/dir[/dim] Initialize in target directory") + console.print(" [dim]drone @cli aipass init /path/to/dir MyProj[/dim] Initialize with custom name") + console.print() + + console.print("[bold cyan]WHAT IT CREATES:[/bold cyan]") + console.print() + console.print(" [green]1.[/green] {NAME}_REGISTRY.json Project registry with UUID") + console.print(" [green]2.[/green] .trinity/passport.json Project identity") + console.print(" [green]3.[/green] .trinity/local.json Local context (empty)") + console.print(" [green]4.[/green] .trinity/observations.json Observations (empty)") + console.print(" [green]5.[/green] .aipass/aipass_local_prompt.md Local prompt") + console.print(" [green]6.[/green] AIPASS.md Project prompt") + console.print() + + console.print("[dim]Commands: init, init --help[/dim]") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """Handle 'init' command. + + Args: + command: The subcommand string (e.g. "init") + args: Remaining positional arguments after the subcommand + + Returns: + True if the command was handled, False otherwise + """ + if command != "init": + return False + + # Handle help flag + if args and args[0] in ("--help", "-h", "help"): + print_help() + return True + + # Parse positional args: [target_dir] [project_name] + caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd()) + target = Path(args[0]) if args else Path(caller_cwd) + project_name = args[1] if len(args) > 1 else None + + try: + result = init_project(target, project_name) + except ValueError as exc: + error(str(exc), suggestion="Pass a project name explicitly") + sys.exit(1) + except FileExistsError as exc: + error(str(exc), suggestion="Remove the existing file to re-initialize") + sys.exit(1) + except OSError as exc: + error(f"Filesystem error: {exc}") + sys.exit(1) + + # Display results + header("Project Initialized") + console.print(f" [bold]{result['project_name']}[/bold]") + console.print() + success( + f"Created {len(result['created_files'])} files", + registry=f"{result['registry_file']} (id: {result['registry_id'][:8]}...)", + target=result["target"], + ) + console.print() + console.print("[dim]Files created:[/dim]") + for f in result["created_files"]: + console.print(f" [dim]{f}[/dim]") + console.print() + + return True + + +# ============================================================================= +# MODULE EXPORTS +# ============================================================================= + +__all__ = [ + "handle_command", +] + + +# ============================================================================= +# ENTRY POINT (SEED pattern) +# ============================================================================= + +if __name__ == "__main__": + try: + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + if sys.argv[1] in ("--help", "-h", "help"): + print_help() + sys.exit(0) + + command = sys.argv[1] + cmd_args = sys.argv[2:] if len(sys.argv) > 2 else [] + + if handle_command(command, cmd_args): + sys.exit(0) + else: + console.print(f"[red]Unknown command: {command}[/red]") + console.print("[dim]Run 'python3 init_project.py --help' for usage[/dim]") + sys.exit(1) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + sys.exit(1)