Merge pull request #655 from AIOSAI/dev

aipass install one-command bootstrap (DPLAN-0233) + hide bugged HVTrust badge + devpulse prompt clarity

- 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, @aipass suite green.
- README: HVTrust badge commented out (hvtracker v4.1 grade bug, issue #109 filed)
- CHANGELOG 2026-07-05 section; devpulse prompt sole-git-writer note
This commit is contained in:
AIPass
2026-07-05 13:10:11 -07:00
committed by GitHub
10 changed files with 862 additions and 23 deletions
+32
View File
@@ -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).
+2 -1
View File
@@ -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)
<!-- HVTrust badge temporarily hidden 2026-07-05: hvtracker methodology v4.1 recalibration bug is showing Grade D/~10 (dimensions on the detail page sum to ~78); correction filed as hvtracker issue #109. Restore when resolved. Was:
[![HVTrust](https://hvtracker.net/badge/aipass.svg)](https://hvtracker.net/agents/aipass) -->
<p align="center">
<img src="assets/logo.png" alt="AIPass" width="400" />
+22 -2
View File
@@ -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 <cmd> 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 <cmd> 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",
@@ -271,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",
+5 -3
View File
@@ -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
+49 -3
View File
@@ -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]"
+18 -14
View File
@@ -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")
+385
View File
@@ -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)
+32
View File
@@ -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
+315
View File
@@ -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
@@ -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`.