diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..6844b44a --- /dev/null +++ b/setup.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +# NOT a setuptools setup.py — this is the AIPass cross-platform installer. +# Runs on Linux, macOS, and Windows (Python 3.10+). +# Usage: python setup.py OR python3 setup.py +""" +AIPass cross-platform setup script. + +Equivalent to setup.sh but works on Windows without Git Bash. +Performs the same steps: create venv, install package, verify entry points, +create secrets directory, seed .env, generate registry, bootstrap branches, +and set up global CLI access. +""" + +import json +import os +import platform +import shutil +import subprocess +import sys +from datetime import date +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _is_windows() -> bool: + return platform.system() == "Windows" + + +def _venv_bin() -> Path: + """Return the venv executables directory (OS-aware).""" + if _is_windows(): + return REPO_ROOT / ".venv" / "Scripts" + return REPO_ROOT / ".venv" / "bin" + + +def _venv_exe(name: str) -> Path: + """Return path to a venv executable by name.""" + if _is_windows(): + return _venv_bin() / f"{name}.exe" + return _venv_bin() / name + + +def _run(cmd: list, check: bool = True, **kwargs) -> subprocess.CompletedProcess: + """Print and run a subprocess command.""" + print(f" $ {' '.join(str(c) for c in cmd)}") + return subprocess.run(cmd, check=check, **kwargs) + + +# --------------------------------------------------------------------------- +# Steps +# --------------------------------------------------------------------------- + +def step_create_venv() -> None: + """[1] Create .venv using sys.executable (avoids python3 vs python ambiguity).""" + print("\n[1/9] Creating virtual environment ...") + venv_path = REPO_ROOT / ".venv" + if venv_path.exists(): + print(" Removing existing .venv for a clean install ...") + shutil.rmtree(venv_path) + _run([sys.executable, "-m", "venv", str(venv_path)]) + print(f" Created: {venv_path}") + + +def step_install() -> None: + """[2] Install aipass in editable mode with dev extras.""" + print("\n[2/9] Installing aipass in editable mode ...") + pip = _venv_exe("pip") + _run([str(pip), "install", "--upgrade", "pip", "--quiet"]) + _run([str(pip), "install", "-e", ".[dev]", "--quiet"], cwd=str(REPO_ROOT)) + print(" Installed: aipass[dev]") + + +def step_verify() -> bool: + """[3] Verify drone and aipass CLI entry points work.""" + print("\n[3/9] Verifying CLI entry points ...") + ok = True + + for entry in ("drone", "aipass"): + cmd_path = _venv_exe(entry) + if not cmd_path.exists(): + print(f" {entry:<8} ... FAILED (not found: {cmd_path})") + ok = False + continue + flag = "--help" if entry == "drone" else "--version" + result = subprocess.run([str(cmd_path), flag], capture_output=True) + if result.returncode == 0: + print(f" {entry:<8} ... ok") + else: + print(f" {entry:<8} ... FAILED (exit {result.returncode})") + ok = False + + return ok + + +def step_secrets() -> None: + """[4] Create ~/.secrets/aipass/ with restrictive permissions.""" + print("\n[4/9] Creating secrets directory ...") + secrets_root = Path.home() / ".secrets" + secrets_dir = secrets_root / "aipass" + secrets_dir.mkdir(parents=True, exist_ok=True) + if not _is_windows(): + try: + secrets_root.chmod(0o700) + secrets_dir.chmod(0o700) + except OSError: + pass # Best-effort on non-POSIX filesystems + print(f" Created: {secrets_dir}") + + +def step_env() -> None: + """[5] Seed .env.example into ~/.secrets/aipass/.env if not present.""" + print("\n[5/9] Seeding .env template ...") + env_dest = Path.home() / ".secrets" / "aipass" / ".env" + env_src = REPO_ROOT / ".env.example" + + if env_dest.exists(): + print(" ~/.secrets/aipass/.env already exists — skipping") + elif env_src.exists(): + shutil.copy(env_src, env_dest) + print(f" Copied: .env.example → {env_dest}") + print(" Add your API keys to that file") + else: + print(" No .env.example found — skipping") + + +def step_registry() -> None: + """[6] Generate AIPASS_REGISTRY.json if not present.""" + print("\n[6/9] Generating AIPASS_REGISTRY.json ...") + registry_path = REPO_ROOT / "AIPASS_REGISTRY.json" + if registry_path.exists(): + print(" AIPASS_REGISTRY.json already exists — skipping") + return + + today = date.today().isoformat() + src_dir = REPO_ROOT / "src" / "aipass" + branches = [] + + if src_dir.exists(): + for d in sorted(src_dir.iterdir()): + if d.is_dir() and not d.name.startswith(("_", ".")): + branches.append({ + "name": d.name, + "path": str(d), + "profile": "library", + "description": "", + "email": f"@{d.name}", + "status": "active", + "created": today, + "last_active": today, + }) + + registry = { + "metadata": { + "version": "1.0.0", + "last_updated": today, + "total_branches": len(branches), + }, + "branches": branches, + } + registry_path.write_text(json.dumps(registry, indent=2) + "\n", encoding="utf-8") + print(f" {len(branches)} branches registered → AIPASS_REGISTRY.json") + + +def step_bootstrap_branches() -> None: + """[7] Bootstrap .trinity/ identity and .ai_mail.local/ for each branch.""" + print("\n[7/9] Bootstrapping branch identity files ...") + today = date.today().isoformat() + + branches = [ + ("drone", "src/aipass/drone", "builder", "Command routing and module discovery"), + ("seedgo", "src/aipass/seedgo", "builder", "Standards enforcement and code auditing"), + ("prax", "src/aipass/prax", "builder", "Logging and monitoring system"), + ("cli", "src/aipass/cli", "builder", "Display formatting service"), + ("flow", "src/aipass/flow", "builder", "Workflow and plan management"), + ("ai_mail", "src/aipass/ai_mail", "builder", "Inter-agent messaging and dispatch"), + ("trigger", "src/aipass/trigger", "builder", "Event-driven automation"), + ("spawn", "src/aipass/spawn", "builder", "Branch lifecycle management"), + ("memory", "src/aipass/memory", "builder", "Vector memory bank"), + ("devpulse", "src/aipass/devpulse", "manager", "Orchestration hub and coordination"), + ] + + for name, rel_path, citizen_class, role in branches: + branch_path = REPO_ROOT / rel_path + if not branch_path.exists(): + print(f" @{name:<10} ... skipped (directory not found)") + continue + + created = False + trinity = branch_path / ".trinity" + trinity.mkdir(exist_ok=True) + + passport = trinity / "passport.json" + if not passport.exists(): + passport.write_text(json.dumps({ + "document_metadata": { + "document_type": "identity", + "document_name": f"{name}.PASSPORT", + "version": "1.0.0", + "schema_version": "1.0.0", + "created": today, + "last_updated": today, + "managed_by": name, + }, + "identity": { + "name": name, + "citizen_class": citizen_class, + "role": role, + "status": "active", + }, + }, indent=2) + "\n", encoding="utf-8") + created = True + + local = trinity / "local.json" + if not local.exists(): + local.write_text(json.dumps({ + "document_metadata": { + "document_type": "session_history", + "document_name": f"{name}.LOCAL", + "version": "1.0.0", + "schema_version": "1.0.0", + "created": today, + "last_updated": today, + "managed_by": name, + "tags": ["session_tracking", "work_log", name], + "limits": {"max_lines": 600, "note": "Auto-rollover when max_lines exceeded"}, + "status": {"health": "healthy", "current_lines": 0, "last_health_check": today}, + }, + "active_tasks": { + "today_focus": "First session — explore codebase and capabilities", + "recently_completed": [], + }, + "key_learnings": {}, + "sessions": [], + }, indent=2) + "\n", encoding="utf-8") + created = True + + mail_dir = branch_path / ".ai_mail.local" + mail_dir.mkdir(exist_ok=True) + inbox = mail_dir / "inbox.json" + if not inbox.exists(): + inbox.write_text( + json.dumps({"mailbox": "inbox", "total_messages": 0, "unread_count": 0, "messages": []}) + + "\n", + encoding="utf-8", + ) + created = True + + seedgo_dir = branch_path / ".seedgo" + seedgo_dir.mkdir(exist_ok=True) + bypass = seedgo_dir / "bypass.json" + if not bypass.exists(): + bypass.write_text("{}\n", encoding="utf-8") + created = True + + status = "bootstrapped" if created else "exists (skipped)" + print(f" @{name:<10} ... {status}") + + +def step_global_access() -> None: + """[8/9] Set up global CLI access (symlink on Linux/macOS, PATH hint on Windows).""" + print("\n[8/9] Setting up global CLI access ...") + bin_dir = _venv_bin() + + if _is_windows(): + # [9] Windows: no ln, no sudo — print PATH instructions + print(" Windows detected — symlink not available") + print("") + print(" To use drone from any directory, add the venv to your PATH.") + print(" Choose the method for your shell:") + print(f" PowerShell: $env:PATH = \"{bin_dir};\" + $env:PATH") + print(f" CMD: set PATH={bin_dir};%PATH%") + print(f" Git Bash: export PATH=\"{bin_dir}:$PATH\"") + print("") + print(" To make it permanent (PowerShell):") + print( + f' [Environment]::SetEnvironmentVariable(' + f'"PATH", "{bin_dir};" + ' + f'[Environment]::GetEnvironmentVariable("PATH","User"), "User")' + ) + return + + # Linux/macOS: offer symlink creation + drone_src = _venv_exe("drone") + drone_dst = Path("/usr/local/bin/drone") + + if not drone_src.exists(): + print(f" drone not found at {drone_src} — skipping symlink") + print(f" Add {bin_dir} to your PATH manually") + return + + try: + answer = input(f" Create symlink {drone_dst} → {drone_src}? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "" + + if answer == "y": + result = subprocess.run( + ["sudo", "ln", "-sf", str(drone_src), str(drone_dst)], + check=False, + ) + if result.returncode == 0: + print(f" {drone_dst} -> {drone_src}") + else: + print(" WARN: sudo failed — create manually:") + print(f" sudo ln -sf {drone_src} {drone_dst}") + else: + print(f" Skipped. To add manually:") + print(f" sudo ln -sf {drone_src} {drone_dst}") + print(f" Or add {bin_dir} to your PATH") + + +def step_summary(ok: bool) -> None: + """[9/9] Print success or warning summary.""" + print("\n[9/9] Done") + print("") + if ok: + print("=== Setup complete ===") + print("") + print(f" Python: {sys.version.split()[0]}") + print(f" Venv: {REPO_ROOT / '.venv'}") + if _is_windows(): + print(" Add .venv/Scripts to your PATH (see step 8 above)") + else: + print(" drone is available globally (or activate: source .venv/bin/activate)") + print("") + else: + print("=== Setup finished with warnings ===") + print(" Package installed but CLI verification had issues.") + print(" Check the output above for details.") + print("") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + print("=== AIPass Setup (cross-platform) ===") + print(f" Platform: {platform.system()} {platform.machine()}") + print(f" Python: {sys.version.split()[0]} ({sys.executable})") + print(f" Repo: {REPO_ROOT}") + + if sys.version_info < (3, 10): + print("\nFAIL: Python 3.10+ required") + sys.exit(1) + + step_create_venv() + step_install() + ok = step_verify() + step_secrets() + step_env() + step_registry() + step_bootstrap_branches() + step_global_access() + step_summary(ok) + + if not ok: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/setup.sh b/setup.sh index c4f02fa9..ac81f2f9 100755 --- a/setup.sh +++ b/setup.sh @@ -10,6 +10,19 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" +# --- OS detection --- +# Detect Windows (Git Bash / MSYS2 / Cygwin) — used throughout the script +IS_WINDOWS=0 +case "${OSTYPE:-}" in + msys*|cygwin*|mingw*) IS_WINDOWS=1 ;; + *) + # Fallback: check uname if OSTYPE is unset + if uname -s 2>/dev/null | grep -qi "mingw\|msys\|cygwin"; then + IS_WINDOWS=1 + fi + ;; +esac + echo "=== AIPass Setup ===" echo "Repo root: $SCRIPT_DIR" echo "" @@ -40,7 +53,12 @@ echo "Creating virtual environment at .venv ..." python3 -m venv .venv # --- Activate and install --- -source .venv/bin/activate +# Activate venv (Scripts/ on Windows, bin/ on Linux/macOS) +if [ "$IS_WINDOWS" -eq 1 ] && [ -f ".venv/Scripts/activate" ]; then + source .venv/Scripts/activate +else + source .venv/bin/activate +fi echo "Upgrading pip ..." pip install --upgrade pip --quiet @@ -440,30 +458,43 @@ else echo "Skipping Gemini CLI (not installed)" fi -# --- Create global symlinks for CLI tools --- +# --- Create global symlinks for CLI tools (Linux/macOS only) --- echo "" -echo "Creating global symlinks ..." +if [ "$IS_WINDOWS" -eq 1 ]; then + echo "Windows detected — skipping symlink (ln/sudo not available on Windows)" + echo "" + echo "To use drone from any directory, add the venv to your PATH:" + echo " PowerShell: \$env:PATH = \"$SCRIPT_DIR\\.venv\\Scripts;\" + \$env:PATH" + echo " Git Bash: export PATH=\"$SCRIPT_DIR/.venv/bin:\$PATH\"" + echo " (Add to ~/.bash_profile for Git Bash persistence)" +else + echo "Creating global symlinks ..." + VENV_BIN="$SCRIPT_DIR/.venv/bin" + LOCAL_BIN="/usr/local/bin" -VENV_BIN="$SCRIPT_DIR/.venv/bin" -LOCAL_BIN="/usr/local/bin" - -for cmd in drone; do - if [ -f "$VENV_BIN/$cmd" ]; then - if sudo ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" 2>/dev/null; then - echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd" - else - echo " WARN: Could not create symlink for $cmd (try running with sudo)" - echo " Manual fix: sudo ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd" + for cmd in drone; do + if [ -f "$VENV_BIN/$cmd" ]; then + if sudo ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" 2>/dev/null; then + echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd" + else + echo " WARN: Could not create symlink for $cmd (try running with sudo)" + echo " Manual fix: sudo ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd" + fi fi - fi -done + done +fi # --- Result --- echo "" if [ "$FAIL" -eq 0 ]; then echo "=== Setup complete ===" echo "" - echo "drone is available globally via /usr/local/bin symlink." + if [ "$IS_WINDOWS" -eq 1 ]; then + echo "drone is available in .venv/Scripts/ (or .venv/bin/ for Git Bash)." + echo "Add the appropriate directory to your PATH (see above)." + else + echo "drone is available globally via /usr/local/bin symlink." + fi echo "seedgo is accessed via: drone @seedgo" echo "No venv activation needed for CLI commands." echo "" diff --git a/src/aipass/cli/.seedgo/bypass.json b/src/aipass/cli/.seedgo/bypass.json index b3b333d7..5f47452c 100644 --- a/src/aipass/cli/.seedgo/bypass.json +++ b/src/aipass/cli/.seedgo/bypass.json @@ -25,6 +25,61 @@ "standard": "log_visibility", "reason": "Pure Python bootstrap \u2014 cannot import prax system_logger (circular: prax depends on cli). Uses stdlib logging as fallback." }, + { + "file": "apps/handlers/init/bootstrap.py", + "standard": "help_text", + "reason": "python3 reference is inside a shell command string assigned to _local_prompt_cmd \u2014 not help text, it is the cross-platform hook command itself." + }, + { + "file": "apps/handlers/init/bootstrap.py", + "standard": "debug_print", + "reason": "print() reference is inside a shell command string assigned to _local_prompt_cmd \u2014 not a Python print() call, it is part of the inline python3 -c hook command." + }, + { + "file": "tests/test_bootstrap.py", + "standard": "architecture", + "reason": "Test file lives in tests/ by pytest convention \u2014 not subject to the 3-layer apps/ structure rule." + }, + { + "file": "tests/test_bootstrap.py", + "standard": "encapsulation", + "reason": "Tests must import handlers directly to unit-test private functions (_sanitize_name, init_project). Using a module entry point would prevent unit testing." + }, + { + "file": "tests/test_bootstrap.py", + "standard": "help_text", + "reason": "python3 reference is inside a pytest assertion string describing expected hook behavior \u2014 not help text, it is a test assertion message." + }, + { + "file": "tests/test_bootstrap.py", + "standard": "meta", + "reason": "Test files use pytest docstring convention instead of AIPass META block headers." + }, + { + "file": "setup.py", + "standard": "architecture", + "reason": "Repo-root cross-platform installer — not a branch module, intentionally lives outside apps/ structure." + }, + { + "file": "setup.py", + "standard": "debug_print", + "reason": "All print() calls are intentional CLI installer output, not debug prints." + }, + { + "file": "setup.py", + "standard": "documentation", + "reason": "main() is documented via the module-level docstring; installer entry points follow script convention." + }, + { + "file": "setup.py", + "standard": "error_handling", + "reason": "except OSError: pass in step_secrets() is documented best-effort chmod on non-POSIX filesystems (Windows)." + }, + { + "file": "setup.py", + "standard": "help_text", + "reason": "python3 reference on line 59 is a user-facing PATH instruction string printed to the terminal, not internal help text." + }, { "file": "apps/modules/display.py", "standard": "silent_catch", diff --git a/src/aipass/cli/apps/handlers/init/bootstrap.py b/src/aipass/cli/apps/handlers/init/bootstrap.py index d90e64b4..0d12de25 100644 --- a/src/aipass/cli/apps/handlers/init/bootstrap.py +++ b/src/aipass/cli/apps/handlers/init/bootstrap.py @@ -414,20 +414,20 @@ def _claude_settings(aipass_home: str | None = None) -> str: Installs two UserPromptSubmit hooks: 1. Global prompt — injects .aipass/aipass_global_prompt.md from CWD. - 2. Local prompt — walks up from CWD to find .aipass/aipass_local_prompt.md - (branch-level prompt, e.g. inside src//). + 2. Local prompt — walks up from CWD via pathlib.Path.parents to find + .aipass/aipass_local_prompt.md (cross-platform; works on Windows). Args: aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME. """ _local_prompt_cmd = ( - "dir=$(pwd); " - "while [ \"$dir\" != \"/\" ]; do " - "if [ -f \"$dir/.aipass/aipass_local_prompt.md\" ]; then " - "cat \"$dir/.aipass/aipass_local_prompt.md\"; break; " - "fi; " - "dir=$(dirname \"$dir\"); " - "done" + 'python3 -c "' + 'from pathlib import Path; ' + "p=next((x/'.aipass'/'aipass_local_prompt.md' " + 'for x in [Path.cwd(),*Path.cwd().parents] ' + "if (x/'.aipass'/'aipass_local_prompt.md').exists()),None); " + "p and print(p.read_text(encoding='utf-8'),end='')" + '"' ) data: dict = { "hooks": { diff --git a/src/aipass/cli/tests/test_bootstrap.py b/src/aipass/cli/tests/test_bootstrap.py index effb11ce..3ba17d42 100644 --- a/src/aipass/cli/tests/test_bootstrap.py +++ b/src/aipass/cli/tests/test_bootstrap.py @@ -297,7 +297,8 @@ def test_init_project_settings_has_two_hooks(tmp_path): # Second hook: local prompt walk-up local_cmd = hooks[1]["hooks"][0]["command"] assert "aipass_local_prompt.md" in local_cmd - assert "dirname" in local_cmd, "Local prompt hook should walk up with dirname" + assert "python3" in local_cmd, "Local prompt hook should use python3 for cross-platform support" + assert "parents" in local_cmd, "Local prompt hook should use pathlib.Path.parents for traversal" def test_init_project_global_prompt_content(tmp_path):