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 @@
[](https://codecov.io/gh/AIOSAI/AIPass)
[](https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass)
[](https://www.bestpractices.dev/projects/13095)
-[](https://hvtracker.net/agents/aipass)
+
diff --git a/src/aipass/aipass/.seedgo/bypass.json b/src/aipass/aipass/.seedgo/bypass.json
index 7a2e37b0..f40d11af 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",
@@ -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",
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`.