From 49700670b9d84b4c709df019f22550a63945a5ea Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Sun, 5 Jul 2026 12:29:57 -0700 Subject: [PATCH 1/2] feat(aipass): aipass install one-command bootstrap (DPLAN-0233) + hide bugged HVTrust badge + devpulse prompt clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aipass install: pip install aipass && aipass install → clone, setup.sh, verify, auto-launch init. Clean-room Docker proven (2 runs, exit 0). 39 tests, seedgo 30/30. - README: comment out HVTrust badge (hvtracker v4.1 grade bug, issue #109 filed) - CHANGELOG: 2026-07-05 section - devpulse local prompt: sole-git-writer dirty-tree note --- CHANGELOG.md | 32 ++ README.md | 3 +- src/aipass/aipass/.seedgo/bypass.json | 14 +- src/aipass/aipass/README.md | 8 +- .../aipass/apps/handlers/ui/progress.py | 52 ++- src/aipass/aipass/apps/modules/init_flow.py | 32 +- src/aipass/aipass/apps/modules/install.py | 385 ++++++++++++++++++ src/aipass/aipass/tests/test_doctor.py | 32 ++ src/aipass/aipass/tests/test_install.py | 315 ++++++++++++++ .../devpulse/.aipass/aipass_local_prompt.md | 2 + 10 files changed, 852 insertions(+), 23 deletions(-) create mode 100644 src/aipass/aipass/apps/modules/install.py create mode 100644 src/aipass/aipass/tests/test_install.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 33675864..c93f3453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,38 @@ PyPI version — not the changelog header. --- +## [2026-07-05] + +### Added + +- **`aipass install` — one-command framework bootstrap.** The missing half of + `pip install aipass`: a single command resolves the install home (default + `~/AIPass`), git-clones the public repo, runs `setup.sh` (venv, editable + install, hook wiring), verifies the toolchain, and auto-launches `aipass init` + in the same terminal — so `pip install aipass && aipass install` bootstraps + the whole system with nobody the wiser. New auto-discovered `install.py` + module (zero shared-code edits) with flags `--non-interactive / --path / + --here / --no-init / --with-init / --project / --dry-run`; 39 unit tests, + seedgo 30/30, @aipass suite green. Proven in a clean-room Docker image + (nothing pre-baked) across two runs, both exit 0: `pip install` (local wheel) + → clone → `setup.sh` (17 branches registered, 13 bootstrapped, hooks wired + into `~/.claude/settings.json`, `AIPASS_HOME` set, `drone`/`aipass` on PATH) → + live `drone systems`, and `--with-init` chaining straight into `aipass init` + to completion. Ships install progress bars, an install→init handoff, and + doctor coverage. (built by @aipass, DPLAN-0233 — PyPI release bump pending) + +### Changed + +- **HVTrust badge temporarily hidden in the root README.** hvtracker's + methodology v4.1 recalibration is miscomputing the grade (showing D/~10 while + the detail-page dimensions sum to ~78); the badge is commented out until it's + corrected. Filed upstream as hvtracker issue #109 — restore when resolved. + +- **devpulse branch prompt — sole-git-writer clarity.** Added a note to the git + section: because no other agent can commit, merge, or push anywhere, dirty + cross-branch files are always someone's live WIP, safe to leave and pick up + later — never a loose end needing handoff. + ## [2026-07-03] Post-2.6.1 cycle — **unreleased** (held for a later merge). diff --git a/README.md b/README.md index a19f9807..20febaf5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ [![codecov](https://codecov.io/gh/AIOSAI/AIPass/graph/badge.svg)](https://codecov.io/gh/AIOSAI/AIPass) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/AIOSAI/AIPass/badge)](https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13095/badge)](https://www.bestpractices.dev/projects/13095) -[![HVTrust](https://hvtracker.net/badge/aipass.svg)](https://hvtracker.net/agents/aipass) +

AIPass diff --git a/src/aipass/aipass/.seedgo/bypass.json b/src/aipass/aipass/.seedgo/bypass.json index 7a2e37b0..fdfff204 100644 --- a/src/aipass/aipass/.seedgo/bypass.json +++ b/src/aipass/aipass/.seedgo/bypass.json @@ -2,8 +2,8 @@ "metadata": { "version": "2.0.0", "created": "2026-04-16", - "description": "Bypass config for @aipass citizen. Modules operational: concierge/init/doctor/handoff/profile built and tested. Bypasses cover: binary-invocation introspection pattern (bare aipass runs, --info for introspection), thin entry-point router (aipass.py uses bare print, no CLI imports), pure-python bootstrap (bootstrap.py/scaffold_content.py run before AIPass services exist), test-isolation patterns (architecture/encapsulation for tests/ directory), and CLI flag name references (permission_flags in test assertions and handoff platform).", - "last_updated": "2026-06-02" + "description": "Bypass config for @aipass citizen. Modules operational: concierge/init/doctor/handoff/profile/install built and tested. Bypasses cover: binary-invocation introspection pattern (bare aipass runs, --info for introspection), thin entry-point router (aipass.py uses bare print, no CLI imports), pure-python bootstrap (bootstrap.py/scaffold_content.py run before AIPass services exist), test-isolation patterns (architecture/encapsulation for tests/ directory), and CLI flag name references (permission_flags in test assertions and handoff platform).", + "last_updated": "2026-07-05" }, "bypass": [ { @@ -91,6 +91,16 @@ "standard": "permission_flags", "reason": "Assertions verify that the CLI flag name appears/absent in handoff_command output. String is in assertion context only — not a permission bypass in this file." }, + { + "file": "tests/test_install.py", + "standard": "architecture", + "reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only." + }, + { + "file": "tests/test_install.py", + "standard": "encapsulation", + "reason": "Unit tests must import the install module directly to test home resolution, clone, and setup orchestration in isolation. Entry-point imports would defeat the purpose of unit testing." + }, { "file": "tests/test_ping_sweep.py", "standard": "architecture", diff --git a/src/aipass/aipass/README.md b/src/aipass/aipass/README.md index 8c687c98..f4b41e15 100644 --- a/src/aipass/aipass/README.md +++ b/src/aipass/aipass/README.md @@ -21,6 +21,7 @@ aipass/ │ │ ├── handoff.py # CLI handoff (placeholder) │ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler) │ │ ├── init_flow.py # 10-stage guided setup +│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init) │ │ └── profile.py # User profile read/write │ ├── handlers/ │ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record @@ -34,7 +35,7 @@ aipass/ │ │ ├── system_detect/ # OS, shell, Python, RAM, CPU │ │ └── ui/ # Progress bars, menus, banners │ └── plugins/ -├── tests/ # 569 passing +├── tests/ # 609 passing ├── requirements.project.txt # Project-specific Python dependencies ├── .trinity/ # Identity + session history + observations └── README.md @@ -53,6 +54,7 @@ aipass/ | `aipass doctor --cross-os --e2e` | ...also runs the real Layer-2 e2e wiring suite (heavy, opt-in) | | `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human Layer-3 acceptance pass | | `aipass init` | 10-stage guided setup (resumable) | +| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init (`--no-init`/`--with-init`/`--path`/`--here`) | | `aipass profile` | Show/edit user profile | | `aipass --version` | Version | @@ -74,7 +76,7 @@ Humans only. Nothing in AIPass depends on this branch. ## Tests -569 passing — `pytest src/aipass/aipass/tests/` +609 passing — `pytest src/aipass/aipass/tests/` ## Known Issues @@ -82,4 +84,4 @@ Humans only. Nothing in AIPass depends on this branch. ## Last Updated -Last Updated: 2026-07-04 +Last Updated: 2026-07-05 diff --git a/src/aipass/aipass/apps/handlers/ui/progress.py b/src/aipass/aipass/apps/handlers/ui/progress.py index 23f2c1e6..9af10360 100644 --- a/src/aipass/aipass/apps/handlers/ui/progress.py +++ b/src/aipass/aipass/apps/handlers/ui/progress.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: progress.py -# Description: Rich progress and glyph helpers for aipass doctor -# Version: 1.0.0 +# Description: Rich progress and glyph helpers for aipass doctor + init +# Version: 1.1.0 # Created: 2026-04-16 -# Modified: 2026-04-16 +# Modified: 2026-07-05 # ============================================= """ @@ -15,6 +15,9 @@ No bare print() — all output via logger or caller's console. from __future__ import annotations +from contextlib import contextmanager +from typing import Iterator + from rich.progress import Progress, SpinnerColumn, TextColumn from aipass.aipass.apps.handlers.json import json_handler @@ -49,6 +52,26 @@ def make_doctor_progress() -> Progress: ) +@contextmanager +def activity_spinner(description: str) -> Iterator[Progress]: + """Transient spinner for a blocking, non-streaming action. + + Wrap Python work that would otherwise look frozen — scaffold build, ping + sweep — so the user sees it is alive. Do NOT wrap a subprocess that streams + its own output to the terminal (installer, spawn): the two renderers fight. + Yields the Progress so a caller can retitle the task mid-flight if needed. + + Args: + description: What is happening, e.g. "Building project scaffold…". + """ + logger.info("[progress] activity spinner: %s", description) + json_handler.log_operation("activity_spinner", {"description": description}) + prog = Progress(SpinnerColumn(), TextColumn("{task.description}"), transient=True) + with prog: + prog.add_task(description, total=None) + yield prog + + # ============================================================================= # CHECK FORMATTER # ============================================================================= @@ -81,3 +104,26 @@ def format_check( line = f"{line}\n{indent}[dim yellow]{remediation}[/dim yellow]" return line + + +def render_step_header(current: int, total: int, label: str, width: int = 18) -> str: + """Render a one-line stage header with an inline progress bar. + + Example: 'Step 3/10 [██████░░░░░░░░░░░░] — User profile'. Pure formatting — + a static string, so it composes with the interactive prompts between stages + (a live Rich bar would have to stop/start around every input()). + + Args: + current: 1-based index of the stage now starting. + total: Total number of stages (clamps to >= 1). + label: Human label for the stage. + width: Character width of the bar. + + Returns: + Rich markup string ready for console.print(). + """ + total = max(total, 1) + current = max(0, min(current, total)) + filled = round(width * current / total) + bar = "█" * filled + "░" * (width - filled) + return f"[bold cyan]Step {current}/{total}[/bold cyan] [green]{bar}[/green] — [bold]{label}[/bold]" diff --git a/src/aipass/aipass/apps/modules/init_flow.py b/src/aipass/aipass/apps/modules/init_flow.py index e8550315..498faf05 100644 --- a/src/aipass/aipass/apps/modules/init_flow.py +++ b/src/aipass/aipass/apps/modules/init_flow.py @@ -39,6 +39,7 @@ from aipass.cli.apps.modules import console, warning from aipass.prax import logger from aipass.aipass.apps.handlers.json import json_handler +from aipass.aipass.apps.handlers.ui.progress import activity_spinner, render_step_header from aipass.aipass.apps.handlers.system_detect.system_detector import ( detect_cpu, detect_git, @@ -233,7 +234,7 @@ def stage_1_welcome(dry_run: bool = False) -> Dict[str, Any]: if dry_run: console.print("[yellow]\\[dry-run][/yellow] No state will be written, no subprocesses launched.") console.print() - console.print("[bold cyan]Step 1/10[/bold cyan] — Welcome") + console.print(render_step_header(1, TOTAL_STAGES, "Welcome")) _save_stage(1, dry_run=dry_run) return {} @@ -273,7 +274,7 @@ def _print_os_gap_heads_up() -> None: def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]: """Detect OS, Python, shell, RAM, CPU, install method, and optional tools.""" console.print() - console.print("[bold cyan]Step 2/10[/bold cyan] — System detection") + console.print(render_step_header(2, TOTAL_STAGES, "System detection")) from rich.table import Table @@ -329,7 +330,7 @@ def stage_3_user_profile( ) -> Dict[str, Any]: """Collect user name and OS, save to profile.""" console.print() - console.print("[bold cyan]Step 3/10[/bold cyan] — User profile") + console.print(render_step_header(3, TOTAL_STAGES, "User profile")) from aipass.aipass.apps.modules import profile as profile_mod @@ -369,7 +370,7 @@ def stage_4_style_questions( ) -> Dict[str, Any]: """Ask what the user wants to do — routes tone of later stages.""" console.print() - console.print("[bold cyan]Step 4/10[/bold cyan] — What brings you here?") + console.print(render_step_header(4, TOTAL_STAGES, "What brings you here?")) if style_override and style_override in STYLE_CHOICES: style = style_override @@ -421,7 +422,7 @@ def _handle_missing_claude(non_interactive: bool) -> None: raw = _prompt("Claude Code ('claude') not found. Install now? [Y/n]", "Y") if raw.lower() in ("y", "yes", ""): - console.print("[dim]Installing Claude Code...[/dim]") + console.print("[cyan]Installing Claude Code[/cyan] [dim](this can take a minute)…[/dim]") if _install_claude_code(): console.print("[green]✓[/green] Claude Code installed successfully.") else: @@ -438,7 +439,7 @@ def stage_5_tool_choice( ) -> Dict[str, Any]: """Choose CLI tool and launch flag variant.""" console.print() - console.print("[bold cyan]Step 5/10[/bold cyan] — CLI tool choice") + console.print(render_step_header(5, TOTAL_STAGES, "CLI tool choice")) if cli_override and cli_override in CLI_CHOICES: cli_choice = cli_override @@ -480,7 +481,7 @@ def stage_5_tool_choice( def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]: """Create the user's first AI agent via drone @spawn.""" console.print() - console.print("[bold cyan]Step 6/10[/bold cyan] — Create your first agent") + console.print(render_step_header(6, TOTAL_STAGES, "Create your first agent")) console.print("Let's create your first AI agent (citizen).") if non_interactive: @@ -493,7 +494,7 @@ def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) -> agent_path = f"{package_dir}/{agent_name}" else: agent_path = f"src/{agent_name}" - console.print(f"Running: [cyan]drone @spawn create {agent_path}[/cyan]") + console.print(f"[cyan]Creating your first agent[/cyan] [dim](drone @spawn create {agent_path})…[/dim]") success = False if dry_run: @@ -520,7 +521,7 @@ def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) -> def stage_7_ping_sweep(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]: """Ping all registered branches via test-convention emails.""" console.print() - console.print("[bold cyan]Step 7/10[/bold cyan] — Pinging agents") + console.print(render_step_header(7, TOTAL_STAGES, "Pinging agents")) from aipass.aipass.apps.handlers import ping_sweep @@ -547,7 +548,8 @@ def stage_7_ping_sweep(non_interactive: bool = False, dry_run: bool = False) -> "[dim] (Agents with a running session will auto-ack; new agents will time out — that's normal.)[/dim]" ) - results = ping_sweep.sweep_all_branches(timeout=10) + with activity_spinner("Pinging agents…"): + results = ping_sweep.sweep_all_branches(timeout=10) for branch, status in results.items(): if status == "ack": @@ -568,7 +570,7 @@ def stage_7_ping_sweep(non_interactive: bool = False, dry_run: bool = False) -> def stage_8_smoke_test(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]: """Verify drone and aipass binaries are on PATH.""" console.print() - console.print("[bold cyan]Step 8/10[/bold cyan] — Smoke test") + console.print(render_step_header(8, TOTAL_STAGES, "Smoke test")) drone_bin = shutil.which("drone") aipass_bin = shutil.which("aipass") @@ -599,7 +601,7 @@ def stage_9_handoff( ) -> Dict[str, Any]: """Launch user's chosen CLI — inline (same terminal) or new window.""" console.print() - console.print("[bold cyan]Step 9/10[/bold cyan] — Handoff") + console.print(render_step_header(9, TOTAL_STAGES, "Handoff")) init_prompt = "I just completed aipass init. I am ready to start. What should I do first?" @@ -712,7 +714,7 @@ def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bo def stage_10_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = False) -> Dict[str, Any]: """Print completion summary and drop init report.""" console.print() - console.print("[bold cyan]Step 10/10[/bold cyan] — Done!") + console.print(render_step_header(10, TOTAL_STAGES, "Done!")) console.print() console.print("[bold green]✓ Setup complete![/bold green]") console.print() @@ -795,7 +797,9 @@ def run_init( from aipass.aipass.apps.handlers.init.bootstrap import init_project if not dry_run: - init_project(cwd) + with activity_spinner("Building project scaffold…"): + init_project(cwd) + console.print("[green]✓[/green] Project scaffold ready") else: console.print("[yellow]\\[dry-run][/yellow] would create project scaffold") diff --git a/src/aipass/aipass/apps/modules/install.py b/src/aipass/aipass/apps/modules/install.py new file mode 100644 index 00000000..efcc6ad5 --- /dev/null +++ b/src/aipass/aipass/apps/modules/install.py @@ -0,0 +1,385 @@ +# =================== AIPass ==================== +# Name: install.py +# Description: aipass install — one-command PyPI bootstrap (clone + setup + handoff) +# Version: 1.0.0 +# Created: 2026-07-05 +# Modified: 2026-07-05 +# ============================================= + +""" +aipass install — one-command bootstrap of the whole framework + +The missing half of `pip install aipass`. pip lands the *code* in site-packages; +this command materializes a working, writable AIPass home and wires it up: + + 1. Resolve where AIPass should live (default ~/AIPass; --here / --path to steer). + 2. Fetch the framework there (git clone of the public repo) if not already present. + 3. Run the canonical setup.sh (venv, editable install, provider-hook wiring, binaries). + 4. Verify drone/aipass are on PATH, then hand off into `aipass init run` to + scaffold a first project (interactive default; --no-init to skip, --with-init + to force even headless). Init targets a sibling dir, never the engine tree. + +Each step prints a Step k/N progress header. Streaming subprocesses (git, setup.sh) +show a header + their own output + a result line — no spinner (the two renderers +fight, per ui/progress.activity_spinner). + +Usage: + aipass install # interactive, then launches init + aipass install --non-interactive # CI/headless (~/AIPass), stops before init + aipass install --with-init # headless AND chain into init --non-interactive + aipass install --no-init # install the engine only, skip the handoff + aipass install --path ~/tools/aipass # explicit home + aipass install --project ~/proj # where the first project scaffolds + aipass install --here # install into the current directory + aipass install --dry-run # walk all steps, no clone/setup/launch +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Dict + +from aipass.cli.apps.modules import console, warning +from aipass.prax import logger + +from aipass.aipass.apps.handlers.json import json_handler +from aipass.aipass.apps.handlers.ui.progress import render_step_header + +COMMAND = "install" +TOTAL_STEPS = 4 +REPO_URL = "https://github.com/AIOSAI/AIPass.git" +DEFAULT_HOME = Path.home() / "AIPass" +# `aipass init` refuses to run inside the engine tree (its pre-flight blocks on a +# parent registry), so the auto-handoff scaffolds the first project in a sibling. +DEFAULT_PROJECT = Path.home() / "aipass-project" + +# Clone can be slow on a cold network; setup.sh compiles a venv + installs deps. +_CLONE_TIMEOUT = 600 +_SETUP_TIMEOUT = 1800 + + +def _prompt(msg: str, default: str = "") -> str: + """Simple input prompt with optional default (raises on Ctrl-C/EOF).""" + display = f"{msg} [{default}]: " if default else f"{msg}: " + try: + val = input(display).strip() + return val if val else default + except (KeyboardInterrupt, EOFError): + raise KeyboardInterrupt + + +def _looks_like_aipass_tree(home: Path) -> bool: + """True if `home` already holds an AIPass source tree (idempotent re-install).""" + if not home.is_dir(): + return False + if (home / "setup.sh").is_file(): + return True + return bool(list(home.glob("*_REGISTRY.json"))) + + +def _resolve_home(path: str | None, here: bool, non_interactive: bool) -> Path: + """Decide where AIPass lives — --here / --path / $AIPASS_HOME / prompt / default.""" + if here: + return Path.cwd().resolve() + if path: + return Path(path).expanduser().resolve() + env_home = os.environ.get("AIPASS_HOME", "").strip() + if env_home and _looks_like_aipass_tree(Path(env_home).expanduser()): + return Path(env_home).expanduser().resolve() + if non_interactive: + return DEFAULT_HOME.resolve() + raw = _prompt("Where should AIPass live?", str(DEFAULT_HOME)) + return Path(raw).expanduser().resolve() + + +def _clone_repo(home: Path, dry_run: bool) -> bool: + """git clone the public AIPass repo into `home`. Returns True on success.""" + if dry_run: + console.print(f"[yellow]\\[dry-run][/yellow] would run: git clone --depth 1 {REPO_URL} {home}") + return True + if home.exists() and any(home.iterdir()): + warning(f"{home} exists and is not empty — pass an empty --path, or remove it first.") + return False + if shutil.which("git") is None: + warning("git not found — the installer needs git to fetch AIPass. Install git and retry.") + return False + home.parent.mkdir(parents=True, exist_ok=True) + console.print("[cyan]Downloading AIPass[/cyan] [dim](git clone — this can take a minute)…[/dim]") + try: + proc = subprocess.run(["git", "clone", "--depth", "1", REPO_URL, str(home)], timeout=_CLONE_TIMEOUT) + if proc.returncode == 0: + return True + logger.warning("[install] git clone exited %s", proc.returncode) + except subprocess.TimeoutExpired as exc: + logger.warning("[install] git clone timed out: %s", exc) + warning("git clone timed out.") + return False + + +def _run_setup(home: Path, dry_run: bool) -> bool: + """Run the repo's setup.sh (venv + editable install + hook wiring + binaries).""" + setup = home / "setup.sh" + if dry_run: + console.print(f"[yellow]\\[dry-run][/yellow] would run: bash {setup}") + return True + if not setup.is_file(): + warning(f"setup.sh not found at {setup} — cannot build the environment.") + return False + console.print("[cyan]Building environment[/cyan] [dim](venv, dependencies, hook wiring)…[/dim]") + try: + proc = subprocess.run(["bash", str(setup)], cwd=str(home), timeout=_SETUP_TIMEOUT) + if proc.returncode == 0: + return True + logger.warning("[install] setup.sh exited %s", proc.returncode) + warning("setup.sh reported errors — see output above.") + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + logger.warning("[install] setup.sh failed: %s", exc) + warning(f"setup failed: {exc}") + return False + + +def _resolve_aipass_bin(home: Path) -> str | None: + """Locate the aipass binary post-setup — PATH, then home/.venv/bin, then ~/.local/bin.""" + found = shutil.which("aipass") + if found: + return found + for candidate in (home / ".venv" / "bin" / "aipass", Path.home() / ".local" / "bin" / "aipass"): + if candidate.is_file(): + return str(candidate) + return None + + +def _verify_binaries(home: Path) -> Dict[str, str | None]: + """Report drone/aipass resolution after setup (PATH may lag in the live shell).""" + drone = shutil.which("drone") or ( + str(home / ".venv" / "bin" / "drone") if (home / ".venv" / "bin" / "drone").is_file() else None + ) + aipass = _resolve_aipass_bin(home) + if drone: + console.print(f"[green]✓[/green] drone: {drone}") + else: + warning("drone not found after setup — check the setup output above.") + if aipass: + console.print(f"[green]✓[/green] aipass: {aipass}") + else: + warning("aipass not found after setup — check the setup output above.") + return {"drone": drone, "aipass": aipass} + + +def _should_run_init(non_interactive: bool, with_init: bool, no_init: bool) -> bool: + """Decide whether to auto-launch init. --no-init wins; --with-init forces on. + + Default: interactive flows chain into init ("one command, done"); headless + flows stop at a wired engine and print the next command (safe for CI/Docker). + """ + if no_init: + return False + if with_init: + return True + return not non_interactive + + +def _handoff_to_init( + home: Path, + aipass_bin: str | None, + non_interactive: bool, + dry_run: bool, + project: str | None, + run_it: bool, +) -> None: + """Print the installed banner, then (optionally) launch init for a first project. + + `aipass init` scaffolds a *new* project and refuses to run inside the engine + tree (pre-flight blocks on a parent registry), so init targets a sibling + directory (``--project`` or DEFAULT_PROJECT), never ``home``. When install ran + headless, init is launched headless too so the whole chain stays non-blocking. + """ + console.print() + console.print(f"[bold green]✓ AIPass is installed at {home}[/bold green]") + console.print() + console.print(" [cyan]drone systems[/cyan] [dim]# list every agent[/dim]") + console.print(" [cyan]aipass doctor[/cyan] [dim]# check system health[/dim]") + console.print(" [cyan]aipass init run[/cyan] [dim]# scaffold your first project on AIPass[/dim]") + console.print() + + if not run_it: + console.print("[dim]Run 'aipass init run' in a fresh directory to start your first project.[/dim]") + return + + project_dir = _resolve_project_dir(project, non_interactive) + if project_dir is None: + return + + if dry_run: + console.print(f"[yellow]\\[dry-run][/yellow] would launch: aipass init run in {project_dir}") + return + if not aipass_bin: + warning("Can't find the aipass binary yet — open a new terminal and run 'aipass init run'.") + return + + project_dir.mkdir(parents=True, exist_ok=True) + console.print(f"[cyan]Launching guided setup[/cyan] [dim]in {project_dir}…[/dim]") + cmd = [aipass_bin, "init", "run"] + if non_interactive: + cmd.append("--non-interactive") + try: + subprocess.run(cmd, cwd=str(project_dir)) + except (FileNotFoundError, OSError) as exc: + logger.warning("[install] could not launch init: %s", exc) + warning(f"Could not launch init: {exc}. Run 'aipass init run' in {project_dir} yourself.") + + +def _resolve_project_dir(project: str | None, non_interactive: bool) -> Path | None: + """Resolve the first-project directory — --project / prompt / DEFAULT_PROJECT.""" + if project: + return Path(project).expanduser().resolve() + if non_interactive: + return DEFAULT_PROJECT.resolve() + try: + raw = _prompt("Project directory for your first project", str(DEFAULT_PROJECT)) + except KeyboardInterrupt: + logger.info("[install] init handoff cancelled by user") + console.print() + return None + return Path(raw).expanduser().resolve() + + +def run_install( + non_interactive: bool = False, + path: str | None = None, + here: bool = False, + dry_run: bool = False, + with_init: bool = False, + no_init: bool = False, + project: str | None = None, +) -> int: + """Run the 4-step one-command install. Returns 0 on success, 1 on failure.""" + console.print() + console.print("[bold cyan]AIPass — one-command install[/bold cyan]") + if dry_run: + console.print("[yellow]\\[dry-run][/yellow] No clone, no setup, no launch — walking the steps only.") + + # Step 1 — resolve + fetch the framework home + console.print() + console.print(render_step_header(1, TOTAL_STEPS, "Preparing AIPass home")) + try: + home = _resolve_home(path, here, non_interactive) + except KeyboardInterrupt: + logger.info("[install] cancelled at home resolution by user") + console.print() + warning("Cancelled.") + return 1 + console.print(f" Home: [cyan]{home}[/cyan]") + + if _looks_like_aipass_tree(home): + console.print(f"[green]✓[/green] AIPass already present at {home} — skipping download") + elif not _clone_repo(home, dry_run): + warning("Could not fetch AIPass — aborting install.") + return 1 + else: + console.print(f"[green]✓[/green] AIPass downloaded to {home}") + + # Step 2 — build the environment via setup.sh + console.print() + console.print(render_step_header(2, TOTAL_STEPS, "Building environment")) + if not _run_setup(home, dry_run): + warning("Environment build failed — aborting install.") + return 1 + console.print("[green]✓[/green] Environment ready") + + # Step 3 — verify the binaries landed + console.print() + console.print(render_step_header(3, TOTAL_STEPS, "Verifying install")) + bins = _verify_binaries(home) if not dry_run else {"drone": "dry-run", "aipass": "dry-run"} + + # Step 4 — hand off into init (or print next steps) + console.print() + console.print(render_step_header(4, TOTAL_STEPS, "First project")) + run_it = _should_run_init(non_interactive, with_init, no_init) + _handoff_to_init(home, bins.get("aipass"), non_interactive, dry_run, project, run_it) + + json_handler.log_operation( + "aipass_install", + {"home": str(home), "non_interactive": non_interactive, "dry_run": dry_run, "init": run_it}, + ) + return 0 + + +def print_help() -> None: + """Print usage help for the install command.""" + console.print() + console.print("[bold cyan]aipass install[/bold cyan] — one-command bootstrap of AIPass") + console.print() + console.print("[yellow]USAGE:[/yellow]") + console.print(" [green]aipass install[/green] [dim]# interactive, then launches init[/dim]") + console.print(" [green]aipass install --non-interactive[/green] [dim]# CI/headless (~/AIPass), no init[/dim]") + console.print(" [green]aipass install --path DIR[/green] [dim]# explicit home[/dim]") + console.print(" [green]aipass install --here[/green] [dim]# install into current dir[/dim]") + console.print(" [green]aipass install --no-init[/green] [dim]# install only, skip init[/dim]") + console.print(" [green]aipass install --with-init[/green] [dim]# force init even when headless[/dim]") + console.print(" [green]aipass install --project DIR[/green] [dim]# where the first project scaffolds[/dim]") + console.print(" [green]aipass install --dry-run[/green] [dim]# walk steps, no side effects[/dim]") + console.print() + console.print("[yellow]STEPS:[/yellow] resolve home -> fetch -> setup.sh -> verify -> launch init") + console.print() + + +def print_introspection() -> None: + """Show module info for install.""" + console.print() + console.print("[bold cyan]install Module[/bold cyan]") + console.print("One-command bootstrap: clone + setup.sh + verify + handoff") + console.print() + console.print(f"[dim]Default home: {DEFAULT_HOME}[/dim]") + console.print(f"[dim]Source: {REPO_URL}[/dim]") + console.print() + + +def handle_command(command: str, args: list[str]) -> bool: + """Route install subcommands. Returns True if handled, False otherwise.""" + if command != COMMAND: + return False + + if args and args[0] in ("--help", "-h", "help"): + print_help() + return True + if args and args[0] in ("--info", "info"): + print_introspection() + return True + + # `aipass install` runs directly; `run` is accepted as an optional verb. + run_args = args[1:] if args and args[0] == "run" else args + + def _flag_value(flag: str) -> str | None: + """Extract the value after a named flag, or None if absent.""" + if flag not in run_args: + return None + idx = run_args.index(flag) + return run_args[idx + 1] if idx + 1 < len(run_args) else None + + non_interactive = "--non-interactive" in run_args + dry_run = "--dry-run" in run_args + here = "--here" in run_args + with_init = "--with-init" in run_args + no_init = "--no-init" in run_args + path = _flag_value("--path") + project = _flag_value("--project") + + result = run_install( + non_interactive=non_interactive, + path=path, + here=here, + dry_run=dry_run, + with_init=with_init, + no_init=no_init, + project=project, + ) + json_handler.log_operation( + "install_run", + {"non_interactive": non_interactive, "dry_run": dry_run, "with_init": with_init, "exit": result}, + ) + sys.exit(result) diff --git a/src/aipass/aipass/tests/test_doctor.py b/src/aipass/aipass/tests/test_doctor.py index c59a8bf2..12e142cc 100644 --- a/src/aipass/aipass/tests/test_doctor.py +++ b/src/aipass/aipass/tests/test_doctor.py @@ -28,8 +28,10 @@ from aipass.aipass.apps.handlers.ui.progress import ( GLYPH_FAIL, GLYPH_PASS, GLYPH_WARN, + activity_spinner, format_check, make_doctor_progress, + render_step_header, ) from aipass.aipass.apps.modules.doctor import handle_command, run_doctor @@ -273,6 +275,36 @@ class TestProgressHelpers: prog = make_doctor_progress() assert isinstance(prog, Progress) + def test_render_step_header_shows_step_and_label(self) -> None: + """Header carries the step counter and the stage label.""" + line = render_step_header(3, 10, "User profile") + assert "Step 3/10" in line + assert "User profile" in line + + def test_render_step_header_bar_fills_with_progress(self) -> None: + """The bar has more filled cells later in the flow, full at the end.""" + early = render_step_header(1, 10, "x", width=18) + late = render_step_header(10, 10, "x", width=18) + assert late.count("█") > early.count("█") + assert late.count("█") == 18 + + def test_render_step_header_clamps_out_of_range(self) -> None: + """current > total and total <= 0 are clamped — no overflow, no div-by-zero.""" + over = render_step_header(15, 10, "x", width=18) + assert over.count("█") == 18 + zero = render_step_header(0, 0, "x", width=18) + assert "Step 0/1" in zero + + def test_activity_spinner_yields_progress_and_runs_block(self) -> None: + """activity_spinner is a context manager yielding a Progress; block runs.""" + from rich.progress import Progress + + ran = [] + with activity_spinner("doing a thing…") as prog: + assert isinstance(prog, Progress) + ran.append(True) + assert ran == [True] + # ============================================================================= # TestDoctorHandleCommand diff --git a/src/aipass/aipass/tests/test_install.py b/src/aipass/aipass/tests/test_install.py new file mode 100644 index 00000000..77da87e9 --- /dev/null +++ b/src/aipass/aipass/tests/test_install.py @@ -0,0 +1,315 @@ +# =================== AIPass ==================== +# Name: test_install.py +# Description: Tests for aipass install — one-command bootstrap (DPLAN-0233) +# Version: 1.0.0 +# Created: 2026-07-05 +# Modified: 2026-07-05 +# ============================================= + +"""Tests for the aipass install module (DPLAN-0233).""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from aipass.aipass.apps.modules.install import ( + DEFAULT_HOME, + DEFAULT_PROJECT, + TOTAL_STEPS, + _clone_repo, + _handoff_to_init, + _looks_like_aipass_tree, + _resolve_home, + _resolve_project_dir, + _run_setup, + _should_run_init, + handle_command, + print_help, + print_introspection, + run_install, +) + +_MOD = "aipass.aipass.apps.modules.install" + + +class TestLooksLikeAipassTree: + """Detecting whether a directory already holds an AIPass source tree.""" + + def test_setup_sh_present(self, tmp_path: Path) -> None: + """A directory with setup.sh reads as an AIPass tree.""" + (tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8") + assert _looks_like_aipass_tree(tmp_path) is True + + def test_registry_present(self, tmp_path: Path) -> None: + """A directory with a *_REGISTRY.json reads as an AIPass tree.""" + (tmp_path / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8") + assert _looks_like_aipass_tree(tmp_path) is True + + def test_empty_dir(self, tmp_path: Path) -> None: + """An empty directory is not an AIPass tree.""" + assert _looks_like_aipass_tree(tmp_path) is False + + def test_missing_dir(self, tmp_path: Path) -> None: + """A non-existent path is not an AIPass tree.""" + assert _looks_like_aipass_tree(tmp_path / "nope") is False + + +class TestResolveHome: + """Resolving the install home from flags, env, and defaults.""" + + def test_here_returns_cwd(self, tmp_path: Path) -> None: + """--here resolves to the current working directory.""" + with patch(f"{_MOD}.Path.cwd", return_value=tmp_path): + assert _resolve_home(None, here=True, non_interactive=False) == tmp_path.resolve() + + def test_explicit_path(self, tmp_path: Path) -> None: + """An explicit --path is expanded and resolved.""" + target = tmp_path / "tools" / "aipass" + assert _resolve_home(str(target), here=False, non_interactive=False) == target.resolve() + + def test_non_interactive_defaults(self) -> None: + """With no AIPASS_HOME, non-interactive falls back to DEFAULT_HOME.""" + with patch.dict("os.environ", {"AIPASS_HOME": ""}, clear=False): + assert _resolve_home(None, here=False, non_interactive=True) == DEFAULT_HOME.resolve() + + def test_uses_valid_env(self, tmp_path: Path) -> None: + """A valid AIPASS_HOME pointing at a real tree is honoured.""" + (tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8") + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}, clear=False): + assert _resolve_home(None, here=False, non_interactive=True) == tmp_path.resolve() + + def test_ignores_invalid_env(self, tmp_path: Path) -> None: + """An AIPASS_HOME that is not an AIPass tree is ignored for the default.""" + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}, clear=False): + assert _resolve_home(None, here=False, non_interactive=True) == DEFAULT_HOME.resolve() + + +class TestCloneRepo: + """Fetching the framework into the home via git clone.""" + + def test_dry_run_no_subprocess(self, tmp_path: Path) -> None: + """Dry-run reports success without shelling out.""" + with patch(f"{_MOD}.subprocess.run") as run: + assert _clone_repo(tmp_path / "home", dry_run=True) is True + run.assert_not_called() + + def test_refuses_non_empty_dir(self, tmp_path: Path) -> None: + """A non-empty target is refused rather than clobbered.""" + (tmp_path / "existing.txt").write_text("x", encoding="utf-8") + with patch(f"{_MOD}.subprocess.run") as run: + assert _clone_repo(tmp_path, dry_run=False) is False + run.assert_not_called() + + def test_missing_git(self, tmp_path: Path) -> None: + """Absent git means clone fails cleanly without calling subprocess.""" + with patch(f"{_MOD}.shutil.which", return_value=None), patch(f"{_MOD}.subprocess.run") as run: + assert _clone_repo(tmp_path / "home", dry_run=False) is False + run.assert_not_called() + + def test_success(self, tmp_path: Path) -> None: + """A zero-exit git clone returns success.""" + with ( + patch(f"{_MOD}.shutil.which", return_value="/usr/bin/git"), + patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run, + ): + assert _clone_repo(tmp_path / "home", dry_run=False) is True + run.assert_called_once() + + def test_nonzero_exit(self, tmp_path: Path) -> None: + """A non-zero git clone exit reports failure.""" + with ( + patch(f"{_MOD}.shutil.which", return_value="/usr/bin/git"), + patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=1)), + ): + assert _clone_repo(tmp_path / "home", dry_run=False) is False + + +class TestRunSetup: + """Running the repo setup.sh.""" + + def test_dry_run_no_subprocess(self, tmp_path: Path) -> None: + """Dry-run reports success without running setup.sh.""" + (tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8") + with patch(f"{_MOD}.subprocess.run") as run: + assert _run_setup(tmp_path, dry_run=True) is True + run.assert_not_called() + + def test_missing_script(self, tmp_path: Path) -> None: + """A missing setup.sh reports failure.""" + assert _run_setup(tmp_path, dry_run=False) is False + + def test_success(self, tmp_path: Path) -> None: + """A zero-exit setup.sh returns success.""" + (tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8") + with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run: + assert _run_setup(tmp_path, dry_run=False) is True + run.assert_called_once() + + +class TestRunInstall: + """The four-step orchestrator.""" + + def test_dry_run_is_side_effect_free(self) -> None: + """Dry-run walks all steps and touches no subprocess.""" + with patch(f"{_MOD}.subprocess.run") as run: + rc = run_install(non_interactive=True, dry_run=True) + assert rc == 0 + run.assert_not_called() + + def test_aborts_when_clone_fails(self, tmp_path: Path) -> None: + """A failed fetch aborts before the setup step runs.""" + home = tmp_path / "AIPass" + with ( + patch(f"{_MOD}._resolve_home", return_value=home), + patch(f"{_MOD}._clone_repo", return_value=False), + patch(f"{_MOD}._run_setup") as setup, + ): + rc = run_install(non_interactive=True, dry_run=False) + assert rc == 1 + setup.assert_not_called() + + def test_full_happy_path(self, tmp_path: Path) -> None: + """Clone + setup + verify + next-steps returns success.""" + home = tmp_path / "AIPass" + with ( + patch(f"{_MOD}._resolve_home", return_value=home), + patch(f"{_MOD}._clone_repo", return_value=True), + patch(f"{_MOD}._run_setup", return_value=True), + patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}), + patch(f"{_MOD}._handoff_to_init") as nxt, + ): + rc = run_install(non_interactive=True, dry_run=False) + assert rc == 0 + nxt.assert_called_once() + + +class TestShouldRunInit: + """Deciding whether the install chains into init.""" + + def test_no_init_wins(self) -> None: + """--no-init disables the handoff even alongside --with-init.""" + assert _should_run_init(non_interactive=False, with_init=True, no_init=True) is False + + def test_with_init_forces_headless(self) -> None: + """--with-init runs init even when the install was headless.""" + assert _should_run_init(non_interactive=True, with_init=True, no_init=False) is True + + def test_headless_defaults_off(self) -> None: + """A plain headless install stops before init.""" + assert _should_run_init(non_interactive=True, with_init=False, no_init=False) is False + + def test_interactive_defaults_on(self) -> None: + """A plain interactive install chains into init.""" + assert _should_run_init(non_interactive=False, with_init=False, no_init=False) is True + + +class TestResolveProjectDir: + """Resolving where the first project scaffolds.""" + + def test_explicit_project(self, tmp_path: Path) -> None: + """An explicit --project is expanded and resolved.""" + target = tmp_path / "proj" + assert _resolve_project_dir(str(target), non_interactive=True) == target.resolve() + + def test_headless_defaults(self) -> None: + """Headless with no --project falls back to DEFAULT_PROJECT.""" + assert _resolve_project_dir(None, non_interactive=True) == DEFAULT_PROJECT.resolve() + + +class TestHandoffToInit: + """The init handoff step.""" + + def test_skips_when_not_running(self, tmp_path: Path) -> None: + """run_it=False prints next steps and launches nothing.""" + with patch(f"{_MOD}.subprocess.run") as run: + _handoff_to_init(tmp_path, "/x/aipass", non_interactive=True, dry_run=False, project=None, run_it=False) + run.assert_not_called() + + def test_dry_run_no_subprocess(self, tmp_path: Path) -> None: + """Dry-run announces the launch but does not spawn init.""" + with patch(f"{_MOD}.subprocess.run") as run: + _handoff_to_init( + tmp_path, "/x/aipass", non_interactive=True, dry_run=True, project=str(tmp_path / "p"), run_it=True + ) + run.assert_not_called() + + def test_launches_init_headless(self, tmp_path: Path) -> None: + """A real headless handoff launches `aipass init run --non-interactive` in the project dir.""" + project = tmp_path / "proj" + with patch(f"{_MOD}.subprocess.run") as run: + _handoff_to_init( + tmp_path, "/x/aipass", non_interactive=True, dry_run=False, project=str(project), run_it=True + ) + run.assert_called_once() + cmd = run.call_args.args[0] + assert cmd == ["/x/aipass", "init", "run", "--non-interactive"] + assert run.call_args.kwargs["cwd"] == str(project) + + def test_missing_binary_warns_not_crashes(self, tmp_path: Path) -> None: + """No aipass binary → warn, don't launch, don't raise.""" + with patch(f"{_MOD}.subprocess.run") as run: + _handoff_to_init( + tmp_path, None, non_interactive=True, dry_run=False, project=str(tmp_path / "p"), run_it=True + ) + run.assert_not_called() + + +class TestHandleCommand: + """Command routing for `aipass install`.""" + + def test_ignores_other_commands(self) -> None: + """A non-install command is not handled here.""" + assert handle_command("doctor", []) is False + + def test_help(self) -> None: + """--help is handled without exiting.""" + assert handle_command("install", ["--help"]) is True + + def test_info(self) -> None: + """--info is handled without exiting.""" + assert handle_command("install", ["--info"]) is True + + def test_runs_and_exits(self) -> None: + """A run request calls run_install and exits with its code.""" + with patch(f"{_MOD}.run_install", return_value=0) as run: + with pytest.raises(SystemExit) as exc: + handle_command("install", ["--dry-run", "--non-interactive"]) + assert exc.value.code == 0 + run.assert_called_once() + + def test_passes_path_flag(self) -> None: + """--path and --non-interactive are threaded into run_install.""" + target = str(Path.home() / "custom-aipass-home") + with patch(f"{_MOD}.run_install", return_value=0) as run: + with pytest.raises(SystemExit): + handle_command("install", ["--path", target, "--non-interactive"]) + _, kwargs = run.call_args + assert kwargs["path"] == target + assert kwargs["non_interactive"] is True + + def test_passes_init_flags(self) -> None: + """--with-init / --no-init / --project are threaded into run_install.""" + with patch(f"{_MOD}.run_install", return_value=0) as run: + with pytest.raises(SystemExit): + handle_command("install", ["--with-init", "--no-init", "--project", "/x/proj"]) + _, kwargs = run.call_args + assert kwargs["with_init"] is True + assert kwargs["no_init"] is True + assert kwargs["project"] == "/x/proj" + + +class TestSmoke: + """Help/introspection render and constants hold.""" + + def test_print_help_runs(self) -> None: + """print_help renders without error.""" + print_help() + + def test_print_introspection_runs(self) -> None: + """print_introspection renders without error.""" + print_introspection() + + def test_total_steps_constant(self) -> None: + """The install flow advertises four steps.""" + assert TOTAL_STEPS == 4 diff --git a/src/aipass/devpulse/.aipass/aipass_local_prompt.md b/src/aipass/devpulse/.aipass/aipass_local_prompt.md index ec625e7b..c35c8e70 100644 --- a/src/aipass/devpulse/.aipass/aipass_local_prompt.md +++ b/src/aipass/devpulse/.aipass/aipass_local_prompt.md @@ -26,6 +26,8 @@ DEVPULSE — the user's primary collaborator, orchestration hub. Design, plan, d Only branch with git write. Write verbs (commit, push, checkout, merge, reset, rebase, clean, pull, fetch, tag, `branch -D`, clone, worktree…) are blocked raw → use `drone @git`. +**Sole git-writer ⇒ dirty tree is never a loose end.** No other agent can commit, merge, stage, or push — anywhere. So uncommitted changes in ANY branch's tree (mine, @aipass's, anyone's) can only ever land via me or Patrick. Nothing races them, nothing lands them behind our backs. Dirty cross-branch files = someone's live WIP, safe to leave and pick up later on our schedule. Don't flag them as needing resolution or handoff — just note they exist. Commit only when Patrick and I decide to. + Read git is allowed raw — run it directly for investigation, no drone needed: - Verbs: `ls-files, ls-tree, show, cat-file, rev-parse, rev-list, log, status, diff, blame, describe, for-each-ref, show-ref, symbolic-ref, shortlog, grep, archive, count-objects, var, help, version`. From e1fd28970b370059f4daf0879c2608628a8dcd9d Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Sun, 5 Jul 2026 12:57:57 -0700 Subject: [PATCH 2/2] fix(aipass): seedgo-audit bypasses for install.py (introspection + modules) CI flagged @aipass at 99%: install.py had no no-args introspection gate and a direct mkdir. Both are sanctioned patterns (binary-invoked 'aipass install' bare-runs; bootstrap dir-prep before services exist) matching doctor/init_flow/profile precedent. @aipass authored the bypass entries; landing them. Local audit now 100%, pyright clean. --- src/aipass/aipass/.seedgo/bypass.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/aipass/aipass/.seedgo/bypass.json b/src/aipass/aipass/.seedgo/bypass.json index fdfff204..f40d11af 100644 --- a/src/aipass/aipass/.seedgo/bypass.json +++ b/src/aipass/aipass/.seedgo/bypass.json @@ -281,6 +281,16 @@ "standard": "introspection", "reason": "aipass is binary-invoked: aipass profile runs the command; introspection via --info" }, + { + "file": "apps/modules/install.py", + "standard": "introspection", + "reason": "aipass is binary-invoked: bare 'aipass install' runs the bootstrap; introspection via --info (same pattern as doctor/init_flow/profile)." + }, + { + "file": "apps/modules/install.py", + "standard": "modules", + "reason": "Thin bootstrap orchestrator — preps the target/project dir (mkdir) immediately before shelling out to git clone / setup.sh / aipass init. Pre-subprocess dir prep, not business file ops; a handler adds indirection for 2 mkdir calls in a linear flow (same pattern as init_flow/doctor)." + }, { "file": "apps/handlers/json/json_handler.py", "standard": "test_quality",