feat(aipass): adopt verb + shared/ scaffold refactor — projects/ adoption (registry, resident agent, additive scaffold) proven on aipass-site (doctor 31/0); shared/project_home + scaffold_content = one source of truth for init/new/adopt; spawn template registry synced. 786 green, audit-clean. Built by @aipass

This commit is contained in:
AIOSAI
2026-07-21 18:03:44 -07:00
parent 47bef92162
commit c71f389409
16 changed files with 1079 additions and 181 deletions
+13
View File
@@ -9,6 +9,19 @@ PyPI version — not the changelog header.
---
## [2026-07-21]
**feat(aipass)** — `aipass adopt` + shared scaffold refactor: adopt turns an
existing `projects/` directory into a full AIPass project (registry, resident
agent, `.aipass`/`.claude` scaffold) — every write additive, nothing existing
overwritten; unlike `aipass new` it starts from a directory with its own
content and git history. New `shared/` package (`project_home.py`,
`scaffold_content.py`) gives init/new/adopt one source of truth per helper —
`handlers/init/scaffold_content.py` moved there, no per-command copies to
drift. Proven live adopting aipass-site (doctor 31/0). Spawn template registry
synced (template bug chain me→spawn→aipass, fixed S329). 786 tests green,
audit-clean.
## [2026-07-20]
**docs(projects)** — `projects/README.md`: the projects section now ships in
+6
View File
@@ -28,6 +28,12 @@ Projects from the AIPass family that chose to go public:
| **Earmark** | Read code and docs aloud in VS Code with local Piper TTS — pause, resume, pick up where you left off. Ear + bookmark: the plan is notes anchored to where you paused. First public AIPass project. | [AIOSAI/earmark](https://github.com/AIOSAI/earmark) |
| **aipass-site** | The [aipass.ai](https://aipass.ai) website — AIPass's front door on the web. | [AIOSAI/aipass-site](https://github.com/AIOSAI/aipass-site) |
Projects in residence (private, not yet published):
| Project | What it is |
|---|---|
| **Speakeasy** | System-wide voice-to-text: press a hotkey, speak, text lands at your cursor in any app. Local Whisper (faster-whisper), VAD, zero cloud. The voice-IN half of the loop Earmark's voice-OUT completes. Repo moved from ~/Projects/Speakeasy 2026-07-21, own git history intact. |
## Creating one
```bash
+7 -1
View File
@@ -9,6 +9,7 @@ aipass # Show available commands
aipass doctor # Check system health
aipass help what does drone do # Search branch documentation
aipass new myapp --template python # Create a new project
aipass adopt myapp --dry-run # Preview adopting an existing projects/ dir
aipass init # Guided setup (10 stages, resumable)
```
@@ -34,6 +35,7 @@ aipass/
│ │ ├── init_flow.py # 10-stage guided setup
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
│ │ ├── new_project.py # aipass new — create projects inside the installation
│ │ ├── adopt.py # aipass adopt — bring an existing projects/ dir into AIPass
│ │ ├── profile.py # User profile read/write
│ │ ├── trust.py # Trust registry — aipass trust / aipass revoke
│ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off
@@ -42,6 +44,7 @@ aipass/
│ │ ├── handoff_platform/ # Platform-specific handoff detection
│ │ ├── init/ # bootstrap.py, scaffold_content.py
│ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init)
│ │ │ └── adopt.py # Project adoption logic (additive scaffold onto an existing dir)
│ │ ├── json/ # JSON read/write utilities
│ │ ├── ping_sweep/ # Branch reachability verification
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
@@ -50,7 +53,7 @@ aipass/
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
│ │ └── ui/ # Progress bars, menus, banners
│ └── plugins/
├── tests/ # 756 passing
├── tests/ # 785 passing
├── requirements.project.txt # Project-specific Python dependencies
├── .trinity/ # Identity + session history + observations
└── README.md
@@ -74,6 +77,9 @@ aipass/
| `aipass new <name>` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent |
| `aipass new <name> --template python` | Create with Python template (pyproject + src/) |
| `aipass new <name> --no-agent` | Create without resident agent |
| `aipass adopt <name>` | Turn an existing `projects/<name>` directory into a full project — additive scaffold only |
| `aipass adopt <name> --no-agent` | Adopt without a resident agent |
| `aipass adopt <name> --dry-run` | Preview what adoption would do, writes nothing |
| `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry |
| `aipass revoke <path>` | Remove a project from the trust registry |
| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) |
+6
View File
@@ -43,6 +43,7 @@ from aipass.prax import logger
# =============================================================================
_PUBLIC_COMMANDS = {
"adopt": "Turn an existing projects/ directory into a full project",
"doctor": "System health — structure, registry, hooks, tests",
"help": "README-backed Q&A — ask about any branch",
"init": "Guided setup for new users (10 stages, resumable)",
@@ -148,6 +149,10 @@ def print_help(modules: List[Any] | None = None) -> None:
" [green]install[/green] [dim]One-command bootstrap — clone + setup.sh + hooks[/dim]"
)
console.print(" [green]new <name>[/green] [dim]Create a project inside AIPass[/dim]")
console.print(
" [green]adopt <name>[/green] "
"[dim]Turn an existing projects/ directory into a full project[/dim]"
)
console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]")
console.print(
" [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]"
@@ -159,6 +164,7 @@ def print_help(modules: List[Any] | None = None) -> None:
console.print(" [green]aipass doctor[/green] [dim]Check system health[/dim]")
console.print(" [green]aipass help what does drone do[/green] [dim]Search documentation[/dim]")
console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]")
console.print(" [green]aipass adopt myapp --dry-run[/green] [dim]Preview adopting projects/myapp[/dim]")
console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]")
console.print()
@@ -14,7 +14,7 @@ from aipass.aipass.apps.handlers.init.bootstrap import (
is_projects_child,
update_project,
)
from aipass.aipass.apps.handlers.init.scaffold_content import (
from aipass.aipass.shared.scaffold_content import (
global_prompt_md,
inbox_json,
prep_md,
+37 -122
View File
@@ -30,18 +30,23 @@ RULES:
- No hardcoded paths
"""
import importlib.util
import json
import logging
import os
import re
import shutil
import tempfile
import uuid
from datetime import date
from pathlib import Path
from aipass.aipass.apps.handlers.init import scaffold_content as sc
from aipass.aipass.shared import scaffold_content as sc
from aipass.aipass.shared.project_home import (
_claude_local_settings,
_claude_settings,
_detect_aipass_home,
_enroll_project,
is_projects_child,
is_throwaway_path,
)
logger = logging.getLogger(__name__)
@@ -50,25 +55,6 @@ _STALE_MANAGED_FILES: list[Path] = [
]
def is_throwaway_path(path: str | Path) -> bool:
"""True if path is under a temp dir or Claude Code scratchpad."""
resolved = str(Path(path).resolve())
tmp_roots = [tempfile.gettempdir()]
if os.name == "posix":
tmp_roots.append("/tmp")
for root in tmp_roots:
try:
r = str(Path(root).resolve())
except OSError:
logger.info("is_throwaway_path: could not resolve %s", root)
continue
if resolved == r or resolved.startswith(r + os.sep):
return True
if "scratchpad" in resolved.lower():
return True
return False
def _sanitize_name(raw: str) -> str:
"""Sanitize a project name for use in filenames.
@@ -78,23 +64,6 @@ def _sanitize_name(raw: str) -> str:
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
def _detect_aipass_home() -> str | None:
"""Detect the AIPass installation root from the aipass package location.
Returns the parent of the src/ directory (the repo root).
Returns None if detection fails.
"""
try:
spec = importlib.util.find_spec("aipass")
if spec and spec.origin:
# aipass/__init__.py lives at src/aipass/__init__.py
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
return str(Path(spec.origin).resolve().parent.parent.parent)
except Exception as exc:
logger.info("AIPASS_HOME detection skipped: %s", exc)
return None
def _hook_fingerprint(hook_entry: dict) -> str:
"""Extract a comparable fingerprint from a hook entry."""
commands = []
@@ -133,10 +102,11 @@ def _merge_settings(existing: dict, generated: dict) -> dict:
if cleaned_hooks:
merged["hooks"] = cleaned_hooks
# Merge env: generated wins for AIPASS_HOME, preserve user additions
existing_env = existing.get("env", {})
generated_env = generated.get("env", {})
merged["env"] = {**existing_env, **generated_env}
# env: AIPASS_HOME is machine-local — never tracked (see settings.local.json).
# Strip it from any previously-tracked settings.json; preserve other user vars.
existing_env = {k: v for k, v in existing.get("env", {}).items() if k != "AIPASS_HOME"}
if existing_env:
merged["env"] = existing_env
# Merge permissions: union deny/ask lists
existing_perms = existing.get("permissions", {})
@@ -210,75 +180,6 @@ def _merge_hooks_json(existing: dict, template: dict) -> dict:
return merged
def _claude_settings(aipass_home: str | None = None) -> str:
"""Generate .claude/settings.json — env and permissions only.
Hooks are NOT wired at the project level. All AIPass hooks
(prompt injection, identity, email, pre-compact, edit gates) fire
from provider settings (~/.claude/settings.json), installed by
setup.sh. Provider hooks use CWD-walking patterns that work from
any directory in any project.
Project settings only contain:
- env.AIPASS_HOME (so hooks can find the AIPass installation)
- permissions.deny (basic safety rails)
Args:
aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME.
"""
data: dict = {}
data["permissions"] = {
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"EnterPlanMode",
],
}
if aipass_home and not is_throwaway_path(aipass_home):
data["env"] = {"AIPASS_HOME": aipass_home}
elif aipass_home:
logger.warning(
"AIPASS_HOME '%s' is a throwaway path — not writing to settings",
aipass_home,
)
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def _enroll_project(target: Path) -> None:
"""Enroll a project in the trusted-project registry (DPLAN-0244).
Lazy import to keep bootstrap.py free of prax/module-level deps.
"""
try:
from aipass.hooks.apps.handlers.config.trust_registry import enroll
if enroll(str(target)):
logger.info("Enrolled project in trust registry: %s", target)
else:
logger.warning("Trust enrollment failed for %s", target)
except ImportError as exc:
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
def is_projects_child(target: Path) -> bool:
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
of target (i.e. target's parent is named ``projects``).
"""
resolved = target.resolve()
if resolved.parent.name != "projects":
return False
host = resolved.parent.parent
try:
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
except OSError as exc:
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
return False
def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None:
"""Block init if target is inside an agent branch or existing project.
@@ -447,16 +348,23 @@ def init_project(
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
created.append(str(gitignore_path))
# 9. .claude/settings.json
# 9. .claude/settings.json — tracked, permissions only (no machine-local paths)
claude_dir = target / ".claude"
claude_dir.mkdir(exist_ok=True)
settings_path = claude_dir / "settings.json"
if not settings_path.exists():
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
settings_path.write_text(_claude_settings(), encoding="utf-8")
created.append(str(settings_path))
# 9b. .claude/commands/prep.md — /prep session wrap-up slash command
# 9b. .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
if aipass_home and not is_throwaway_path(aipass_home):
local_settings_path = claude_dir / "settings.local.json"
if not local_settings_path.exists():
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
created.append(str(local_settings_path))
# 9c. .claude/commands/prep.md — /prep session wrap-up slash command
# Only prep.md here — memo.md belongs at provider level (~/.claude/commands/)
commands_dir = claude_dir / "commands"
commands_dir.mkdir(exist_ok=True)
@@ -580,11 +488,11 @@ def update_project(target: Path) -> dict:
elif tier_dest.exists():
already_current.append(str(tier_dest))
# settings.json — smart merge: preserve user hooks + env, update AIPass hooks
# settings.json — smart merge: preserve user hooks, update AIPass permissions.
# AIPASS_HOME never lives here — machine-local paths go in settings.local.json.
settings_path = claude_dir / "settings.json"
if not settings_path.exists():
aipass_home = _detect_aipass_home()
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
settings_path.write_text(_claude_settings(), encoding="utf-8")
updated.append(str(settings_path))
else:
existing_content = settings_path.read_text(encoding="utf-8")
@@ -593,9 +501,7 @@ def update_project(target: Path) -> dict:
except json.JSONDecodeError as exc:
logger.info("settings.json parse failed, rebuilding: %s", exc)
existing = {}
existing_env = existing.get("env", {})
aipass_home = existing_env.get("AIPASS_HOME") or _detect_aipass_home()
generated = json.loads(_claude_settings(aipass_home))
generated = json.loads(_claude_settings())
merged = _merge_settings(existing, generated)
merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
if existing != merged:
@@ -604,6 +510,15 @@ def update_project(target: Path) -> dict:
else:
already_current.append(str(settings_path))
# settings.local.json — machine-local AIPASS_HOME (gitignored, create if missing)
if aipass_home and not is_throwaway_path(aipass_home):
local_settings_path = claude_dir / "settings.local.json"
if not local_settings_path.exists():
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
updated.append(str(local_settings_path))
else:
already_current.append(str(local_settings_path))
# hooks.json — union-merge: preserve user enabled, add new hooks from template
hooks_json_path = aipass_dir / "hooks.json"
hook_home = aipass_home or _detect_aipass_home()
@@ -29,6 +29,7 @@ import uuid
from datetime import date
from pathlib import Path
from aipass.aipass.shared import scaffold_content as sc
from aipass.prax import logger
from aipass.spawn import spawn_agent
@@ -117,10 +118,7 @@ def _write_template(target: Path, name: str, template: str) -> list[str]:
)
created.append("README.md")
(target / ".gitignore").write_text(
"__pycache__/\n*.pyc\n.venv\n.trinity/\n.ai_mail.local/\n*.local.json\n*.local/\nlogs/\n.*_REGISTRY.lock\n",
encoding="utf-8",
)
(target / ".gitignore").write_text(sc.gitignore(), encoding="utf-8")
created.append(".gitignore")
if template == "python":
@@ -158,12 +156,13 @@ def _write_template(target: Path, name: str, template: str) -> list[str]:
def _scaffold_aipass(target: Path, name: str) -> list[str]:
"""Write AIPass scaffold files (tiers, hooks, CLAUDE.md, settings, .venv)."""
from aipass.aipass.apps.handlers.init.bootstrap import (
from aipass.aipass.shared.project_home import (
_claude_local_settings,
_claude_settings,
_detect_aipass_home,
_enroll_project,
is_throwaway_path,
)
from aipass.aipass.apps.handlers.init import scaffold_content as sc
created: list[str] = []
aipass_home = _detect_aipass_home()
@@ -206,15 +205,23 @@ def _scaffold_aipass(target: Path, name: str) -> list[str]:
dest.write_text(sc.agents_md(reg), encoding="utf-8")
created.append(md_name)
# .claude/settings.json
# .claude/settings.json — tracked, permissions only (no machine-local paths)
claude_dir = target / ".claude"
claude_dir.mkdir(exist_ok=True)
(claude_dir / "settings.json").write_text(
_claude_settings(aipass_home),
_claude_settings(),
encoding="utf-8",
)
created.append(".claude/settings.json")
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
if aipass_home and not is_throwaway_path(aipass_home):
(claude_dir / "settings.local.json").write_text(
_claude_local_settings(aipass_home),
encoding="utf-8",
)
created.append(".claude/settings.local.json")
# .claude/commands/prep.md
commands_dir = claude_dir / "commands"
commands_dir.mkdir(exist_ok=True)
@@ -0,0 +1,224 @@
# =================== AIPass ====================
# Name: adopt.py
# Description: aipass adopt — bring an existing projects/ directory into AIPass
# Version: 1.0.0
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""
Adopt Handler — PRIVATE implementation
Business logic for `aipass adopt`. Turns an EXISTING directory at
<host>/projects/<name> into a full AIPass project: sealed registry,
resident agent, .aipass/.claude scaffold.
Unlike `aipass new` (which births a brand-new directory), adopt starts
from a directory that already has its own content — possibly its own
git repo, possibly public. Every write is existence-guarded; nothing
already present is ever overwritten. .gitignore is the one file that's
patched rather than skipped, since AIPass local state (.trinity/,
mailbox, *.local.json) must never leak into a tracked commit.
RULES:
- Additive only — never overwrite a file that already exists
- Never touch the target's git history (no git init/add/commit)
- gitignore safety runs BEFORE any other AIPass file is written
- Registry-first: mint the project registry before spawning the agent
- dry_run performs zero filesystem writes and never calls spawn_agent
"""
from __future__ import annotations
from pathlib import Path
from aipass.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.new_project import (
_agent_home,
_registry_name,
_spawn_project_agent,
_write_registry,
)
from aipass.aipass.shared import scaffold_content as sc
from aipass.aipass.shared.project_home import (
_claude_local_settings,
_claude_settings,
_detect_aipass_home,
_enroll_project,
is_projects_child,
is_throwaway_path,
)
GITIGNORE_MARKER = "# AIPass local state"
def _gitignore_safety(target: Path, *, dry_run: bool) -> str:
"""Ensure AIPass-managed paths are gitignored. Returns 'created', 'appended', or 'already-safe'."""
gitignore_path = target / ".gitignore"
if gitignore_path.exists():
existing = gitignore_path.read_text(encoding="utf-8")
if GITIGNORE_MARKER in existing:
return "already-safe"
if not dry_run:
separator = "" if existing.endswith("\n") else "\n"
gitignore_path.write_text(existing + separator + "\n" + sc.gitignore(), encoding="utf-8")
return "appended"
if not dry_run:
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
return "created"
def _write_if_missing(path: Path, content: str, *, dry_run: bool, planned: list[str]) -> None:
"""Record and (unless dry_run) write *content* to *path*, but only if it doesn't already exist."""
if path.exists():
return
if not dry_run:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
planned.append(str(path))
def adopt_project(target: Path, *, no_agent: bool = False, dry_run: bool = False) -> dict:
"""Adopt an existing directory at <host>/projects/<name> as a full AIPass project.
Args:
target: Existing directory to adopt — must be <host>/projects/<name>.
no_agent: Skip resident-agent creation.
dry_run: Report what WOULD happen; performs zero filesystem writes and
never calls spawn_agent.
Returns:
dict with name, target, host, dry_run, registry_id, registry_file,
files, gitignore_action, agent_created, agent_home, spawn_result.
Raises:
RuntimeError: target missing, not a projects/ child, already adopted,
or a resident-agent home path collision.
"""
target = target.resolve()
if not target.is_dir():
raise RuntimeError(f"'{target}' does not exist. `aipass adopt` brings in an EXISTING directory.")
if not is_projects_child(target):
raise RuntimeError(
f"'{target}' is not <host>/projects/<name>. `aipass adopt` only works inside projects/ "
"— use `aipass new` to create a fresh project instead."
)
existing_registry = [f for f in target.iterdir() if f.is_file() and f.name.endswith("_REGISTRY.json")]
if existing_registry:
raise RuntimeError(f"'{target}' is already adopted (has {existing_registry[0].name}).")
host = target.parent.parent
name = target.name
reg = _registry_name(name)
agent_home = _agent_home(target, name)
if not no_agent and agent_home.exists() and any(agent_home.iterdir()):
raise RuntimeError(
f"Name collision: '{agent_home}' already exists and is non-empty — "
"cannot seat a resident agent there. Retry with --no-agent."
)
aipass_home = _detect_aipass_home()
files: list[str] = []
# gitignore safety FIRST — nothing AIPass-managed gets written before the
# target is confirmed to ignore it (target may be a public repo).
gitignore_action = _gitignore_safety(target, dry_run=dry_run)
# Registry — sealed, minted BEFORE spawn (registry-first rule)
registry_id = None
registry_filename = f"{reg}_REGISTRY.json"
if not dry_run:
registry_id, registry_filename = _write_registry(target, name)
files.append(registry_filename)
# .aipass/ — tier files, hooks.json, CLAUDE.md/AGENTS.md
aipass_dir = target / ".aipass"
if aipass_home:
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
src_path = Path(aipass_home) / ".aipass" / tier_file
if src_path.is_file():
_write_if_missing(
aipass_dir / tier_file,
src_path.read_text(encoding="utf-8"),
dry_run=dry_run,
planned=files,
)
hooks_template = Path(aipass_home) / ".aipass" / "project_hooks.json"
if hooks_template.is_file():
hooks_dest = aipass_dir / "hooks.json"
already_had_hooks = hooks_dest.exists()
_write_if_missing(hooks_dest, hooks_template.read_text(encoding="utf-8"), dry_run=dry_run, planned=files)
if not already_had_hooks and not dry_run:
_enroll_project(target)
for md_name in ("CLAUDE.md", "AGENTS.md"):
dest = target / md_name
if dest.exists():
continue
if aipass_home:
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
if tmpl.is_file():
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
_write_if_missing(dest, content, dry_run=dry_run, planned=files)
continue
if md_name == "AGENTS.md":
_write_if_missing(dest, sc.agents_md(reg), dry_run=dry_run, planned=files)
# .claude/settings.json — tracked, permissions only
claude_dir = target / ".claude"
_write_if_missing(claude_dir / "settings.json", _claude_settings(), dry_run=dry_run, planned=files)
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
if aipass_home and not is_throwaway_path(aipass_home):
_write_if_missing(
claude_dir / "settings.local.json",
_claude_local_settings(aipass_home),
dry_run=dry_run,
planned=files,
)
# .claude/commands/prep.md
_write_if_missing(claude_dir / "commands" / "prep.md", sc.prep_md(), dry_run=dry_run, planned=files)
# .venv symlink → AIPass shared runtime
if aipass_home:
venv = Path(aipass_home) / ".venv"
venv_link = target / ".venv"
if venv.is_dir() and not venv_link.exists():
if not dry_run:
venv_link.symlink_to(venv)
files.append(str(venv_link))
# Resident agent — registry MUST exist first (dry_run never spawns)
spawn_result = None
agent_created = False
will_have_agent = not no_agent
if will_have_agent and not dry_run:
spawn_result = _spawn_project_agent(target, name)
agent_created = True
elif will_have_agent and dry_run:
files.append(f"{agent_home} (resident agent — planned)")
json_handler.log_operation(
"adopt_project",
{"name": name, "target": str(target), "dry_run": dry_run},
"adopt",
)
return {
"name": name,
"target": str(target),
"host": str(host),
"dry_run": dry_run,
"registry_id": registry_id,
"registry_file": registry_filename,
"files": files,
"gitignore_action": gitignore_action,
"agent_created": agent_created,
"agent_home": str(agent_home) if will_have_agent else None,
"spawn_result": spawn_result,
}
+127
View File
@@ -0,0 +1,127 @@
# =================== AIPass ====================
# Name: adopt.py
# Description: aipass adopt — bring an existing projects/ directory into AIPass
# Version: 1.0.0
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""
aipass adopt — turn an existing directory under projects/ into a full
AIPass project (registry, resident agent, .aipass/.claude scaffold).
Unlike `aipass new`, adopt starts from a directory that already has its
own content and git history — every write is additive, nothing existing
is ever overwritten.
"""
from __future__ import annotations
import sys
from pathlib import Path
from aipass.aipass.apps.handlers.json import json_handler
from aipass.cli.apps.modules import console, error, success
from aipass.prax import logger
COMMAND = "adopt"
def print_introspection() -> None:
"""Bare invocation — usage pointer."""
console.print()
console.print("[bold cyan]aipass adopt[/bold cyan] — bring an existing directory into projects/")
console.print()
console.print("[dim]Usage: aipass adopt <name-or-path> [--no-agent] [--dry-run][/dim]")
console.print()
def print_help() -> None:
"""Print usage help for the adopt command."""
console.print()
console.print("[bold cyan]aipass adopt[/bold cyan] — adopt an existing directory as an AIPass project")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [green]aipass adopt <name>[/green] [dim]# Adopt <host>/projects/<name>[/dim]")
console.print(" [green]aipass adopt <path>[/green] [dim]# Adopt by relative/absolute path[/dim]")
console.print(" [green]aipass adopt <name> --no-agent[/green] [dim]# Skip resident agent[/dim]")
console.print(" [green]aipass adopt <name> --dry-run[/green] [dim]# Preview, write nothing[/dim]")
console.print()
console.print("[yellow]WHAT IT DOES:[/yellow]")
console.print(" Seats a sealed registry, scaffolds .aipass/.claude, and (unless")
console.print(" --no-agent) creates a resident agent — all ADDITIVE. Never touches")
console.print(" the target's existing git history or tracked files. If the target")
console.print(" has no .gitignore covering AIPass local state, one is created or")
console.print(" appended to first — the target may be a public repo.")
console.print()
console.print("[yellow]REQUIRES:[/yellow]")
console.print(" Target must be an existing directory at <host>/projects/<name>.")
console.print(" Use 'aipass new' instead to create a brand-new project.")
console.print()
def handle_command(command: str, args: list[str]) -> bool:
"""Route the 'adopt' command. Returns True if handled."""
if command != COMMAND:
return False
if not args:
json_handler.log_operation("adopt_usage", {"command": command})
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
json_handler.log_operation("adopt_help", {"command": command})
print_help()
return True
name_or_path = args[0]
known = {"--no-agent", "--dry-run"}
unknown = [a for a in args[1:] if a not in known]
if unknown:
error(f"Unknown option: {unknown[0]}")
print_help()
return True
no_agent = "--no-agent" in args[1:]
dry_run = "--dry-run" in args[1:]
from aipass.aipass.apps.handlers.new_project import find_host_root
from aipass.aipass.apps.handlers.new_project.adopt import adopt_project
target_path = Path(name_or_path)
if not target_path.exists():
host = find_host_root(Path.cwd())
if host is None:
error("Not inside an AIPass installation (no *_REGISTRY.json found).")
sys.exit(1)
target_path = host / "projects" / name_or_path
try:
result = adopt_project(target_path, no_agent=no_agent, dry_run=dry_run)
except RuntimeError as e:
logger.warning("[AIPASS] adopt failed: %s", e)
error(str(e))
sys.exit(1)
console.print()
verb = "Would adopt" if dry_run else "Adopted"
success(f"{verb} '{result['name']}' at {result['target']}")
console.print()
console.print(f" [dim]Registry:[/dim] {result['registry_file']}")
console.print(f" [dim]Gitignore:[/dim] {result['gitignore_action']}")
if result["agent_home"]:
state = "created" if result["agent_created"] else "planned"
console.print(f" [dim]Agent:[/dim] {state} ({result['agent_home']})")
else:
console.print(" [dim]Agent:[/dim] skipped (--no-agent)")
console.print()
console.print("[dim]Files:[/dim]")
for f in result["files"]:
console.print(f" [dim]{f}[/dim]")
console.print()
json_handler.log_operation(
"adopt_project",
{"name": result["name"], "target": result["target"], "dry_run": dry_run},
)
logger.info("[AIPASS] adopt: %s at %s (dry_run=%s)", result["name"], result["target"], dry_run)
return True
+125
View File
@@ -0,0 +1,125 @@
# =================== AIPass ====================
# Name: project_home.py
# Description: Shared AIPass-home detection, project-path validation, and settings content
# Version: 1.0.0
# Created: 2026-07-21
# Modified: 2026-07-21
# =============================================
"""Project home — AIPASS_HOME detection, path validation, settings content.
Shared across every command that scaffolds or inspects an AIPass project
(`aipass init`, `aipass new`, `aipass adopt`), so there is exactly one
source of truth per helper — no per-command copies to drift apart.
Dependency-free: uses only stdlib. Importable before drone/prax exist.
"""
import importlib.util
import json
import logging
import os
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
def is_throwaway_path(path: str | Path) -> bool:
"""True if path is under a temp dir or Claude Code scratchpad."""
resolved = str(Path(path).resolve())
tmp_roots = [tempfile.gettempdir()]
if os.name == "posix":
tmp_roots.append("/tmp")
for root in tmp_roots:
try:
r = str(Path(root).resolve())
except OSError:
logger.info("is_throwaway_path: could not resolve %s", root)
continue
if resolved == r or resolved.startswith(r + os.sep):
return True
if "scratchpad" in resolved.lower():
return True
return False
def _detect_aipass_home() -> str | None:
"""Detect the AIPass installation root from the aipass package location.
Returns the parent of the src/ directory (the repo root).
Returns None if detection fails.
"""
try:
spec = importlib.util.find_spec("aipass")
if spec and spec.origin:
# aipass/__init__.py lives at src/aipass/__init__.py
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
return str(Path(spec.origin).resolve().parent.parent.parent)
except Exception as exc:
logger.info("AIPASS_HOME detection skipped: %s", exc)
return None
def _claude_settings() -> str:
"""Generate .claude/settings.json — permissions only, no machine-local paths.
Hooks are NOT wired at the project level. All AIPass hooks
(prompt injection, identity, email, pre-compact, edit gates) fire
from provider settings (~/.claude/settings.json), installed by
setup.sh. Provider hooks use CWD-walking patterns that work from
any directory in any project.
AIPASS_HOME is a machine-local absolute path — it never belongs in this
tracked file. It goes in .claude/settings.local.json instead, which is
gitignored and merged over tracked settings by Claude Code natively.
See _claude_local_settings().
"""
data: dict = {
"permissions": {
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"EnterPlanMode",
],
},
}
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def _claude_local_settings(aipass_home: str) -> str:
"""Generate .claude/settings.local.json — machine-local env (gitignored)."""
return json.dumps({"env": {"AIPASS_HOME": aipass_home}}, indent=2, ensure_ascii=False) + "\n"
def _enroll_project(target: Path) -> None:
"""Enroll a project in the trusted-project registry (DPLAN-0244).
Lazy import to keep this module free of prax/module-level deps.
"""
try:
from aipass.hooks.apps.handlers.config.trust_registry import enroll
if enroll(str(target)):
logger.info("Enrolled project in trust registry: %s", target)
else:
logger.warning("Trust enrollment failed for %s", target)
except ImportError as exc:
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
def is_projects_child(target: Path) -> bool:
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
of target (i.e. target's parent is named ``projects``).
"""
resolved = target.resolve()
if resolved.parent.name != "projects":
return False
host = resolved.parent.parent
try:
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
except OSError as exc:
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
return False
@@ -1,17 +1,20 @@
# =================== AIPass ====================
# Name: scaffold_content.py
# Description: Template content generators for aipass init scaffold
# Description: Shared template content generators for project scaffolding
# Version: 1.0.0
# Created: 2026-04-22
# Modified: 2026-04-22
# Modified: 2026-07-21
# =============================================
"""
Scaffold Content — template generators for `aipass init`
Scaffold Content — template generators for `aipass init`, `aipass new`, `aipass adopt`
Pure string-returning functions that produce the content for each scaffold
file (CLAUDE.md, AGENTS.md, etc.). Extracted from bootstrap.py to keep
the handler under 700 lines.
file (CLAUDE.md, AGENTS.md, .gitignore, etc.). Shared across every command
that mints AIPass project files, so there is exactly one source of truth
per template — no per-command copies to drift apart.
Dependency-free: uses only stdlib. Importable before drone/prax exist.
RULES:
- Pure Python only (no module/prax/cli imports)
@@ -251,7 +254,7 @@ def gitignore() -> str:
"*.egg-info/\n"
"dist/\n"
"build/\n"
".venv/\n"
".venv\n"
"venv/\n"
"\n"
"# IDE\n"
@@ -267,6 +270,9 @@ def gitignore() -> str:
"\n"
"# Disabled files\n"
"*(disabled)*\n"
"\n"
"# Registry lock\n"
".*_REGISTRY.lock\n"
)
+421
View File
@@ -0,0 +1,421 @@
# =================== AIPass ====================
# Name: test_adopt.py
# Description: Tests for aipass adopt — project adoption handler
# Version: 1.0.0
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Tests for the adopt handler and module.
All file operations use tmp_path to stay fully isolated from the live
filesystem. dry_run tests assert zero filesystem mutation.
"""
import json
import subprocess
from unittest.mock import patch
import pytest # pyright: ignore[reportMissingImports]
from aipass.aipass.apps.handlers.new_project.adopt import (
GITIGNORE_MARKER,
adopt_project,
)
@pytest.fixture()
def host_env(tmp_path):
"""Minimal AIPass host installation with an existing (pre-populated) project dir."""
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "host-id"}, "branches": []}))
projects = tmp_path / "projects"
projects.mkdir()
target = projects / "existing-site"
target.mkdir()
(target / "index.html").write_text("<html></html>\n", encoding="utf-8")
(target / "README.md").write_text("# existing-site\n\nReal content.\n", encoding="utf-8")
return tmp_path, target
def _no_home():
return patch(
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
return_value=None,
)
# ---------------------------------------------------------------------------
# adopt_project — guards / refusals
# ---------------------------------------------------------------------------
def test_adopt_rejects_missing_target(tmp_path):
with pytest.raises(RuntimeError, match="does not exist"):
adopt_project(tmp_path / "nope")
def test_adopt_rejects_non_projects_child(tmp_path):
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
outside = tmp_path / "elsewhere" / "myapp"
outside.mkdir(parents=True)
with pytest.raises(RuntimeError, match="not <host>/projects/<name>"):
adopt_project(outside)
def test_adopt_rejects_already_adopted(host_env):
_, target = host_env
(target / "EXISTING_SITE_REGISTRY.json").write_text("{}")
with pytest.raises(RuntimeError, match="already adopted"):
adopt_project(target, no_agent=True)
def test_adopt_rejects_agent_home_collision(host_env):
_, target = host_env
home = target / "src" / "existing_site" / "existing_site"
home.mkdir(parents=True)
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
with _no_home():
with pytest.raises(RuntimeError, match="Name collision"):
adopt_project(target, no_agent=False)
def test_adopt_allows_collision_with_no_agent(host_env):
"""An occupied agent-home path is fine when --no-agent skips the agent entirely."""
_, target = host_env
home = target / "src" / "existing_site" / "existing_site"
home.mkdir(parents=True)
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["agent_created"] is False
# ---------------------------------------------------------------------------
# adopt_project — gitignore safety
# ---------------------------------------------------------------------------
def test_adopt_creates_gitignore_when_absent(host_env):
_, target = host_env
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["gitignore_action"] == "created"
content = (target / ".gitignore").read_text(encoding="utf-8")
assert GITIGNORE_MARKER in content
assert ".trinity/" in content
def test_adopt_appends_gitignore_when_marker_absent(host_env):
_, target = host_env
(target / ".gitignore").write_text("node_modules/\ndist/\n", encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["gitignore_action"] == "appended"
content = (target / ".gitignore").read_text(encoding="utf-8")
assert "node_modules/" in content
assert GITIGNORE_MARKER in content
assert ".trinity/" in content
def test_adopt_skips_gitignore_when_marker_present(host_env):
_, target = host_env
(target / ".gitignore").write_text(f"{GITIGNORE_MARKER}\n.trinity/\n", encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["gitignore_action"] == "already-safe"
content = (target / ".gitignore").read_text(encoding="utf-8")
assert content.count(GITIGNORE_MARKER) == 1
def test_adopt_gitignore_covers_symlinked_venv_and_registry_lock(host_env, tmp_path):
"""Live-verification regression: a symlinked .venv (not a real dir) and a
registry lock file must both be actually ignored by real git, not just
present as a string in the .gitignore content."""
host, target = host_env
aipass_home = tmp_path / "fake_aipass_home"
(aipass_home / ".venv").mkdir(parents=True)
subprocess.run(["git", "init"], cwd=target, capture_output=True, text=True, check=True)
with (
patch(
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
return_value=str(aipass_home),
),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
):
adopt_project(target, no_agent=True)
venv_link = target / ".venv"
assert venv_link.is_symlink()
lock_file = target / ".EXISTING-SITE_REGISTRY.lock"
lock_file.write_text("", encoding="utf-8")
result = subprocess.run(
["git", "check-ignore", ".venv", ".EXISTING-SITE_REGISTRY.lock"],
cwd=target,
capture_output=True,
text=True,
)
assert result.returncode == 0
assert ".venv" in result.stdout.split()
assert ".EXISTING-SITE_REGISTRY.lock" in result.stdout.split()
# ---------------------------------------------------------------------------
# adopt_project — additive scaffold, existing files untouched
# ---------------------------------------------------------------------------
def test_adopt_never_overwrites_existing_readme(host_env):
_, target = host_env
original = (target / "README.md").read_text(encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
adopt_project(target, no_agent=True)
assert (target / "README.md").read_text(encoding="utf-8") == original
def test_adopt_never_touches_existing_tracked_files(host_env):
_, target = host_env
original = (target / "index.html").read_text(encoding="utf-8")
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
adopt_project(target, no_agent=True)
assert (target / "index.html").read_text(encoding="utf-8") == original
def test_adopt_writes_registry_and_settings(host_env):
_, target = host_env
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
result = adopt_project(target, no_agent=True)
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
assert (target / "EXISTING-SITE_REGISTRY.json").exists()
reg = json.loads((target / "EXISTING-SITE_REGISTRY.json").read_text())
assert reg["metadata"]["name"] == "EXISTING-SITE"
assert (target / ".claude" / "settings.json").exists()
settings = json.loads((target / ".claude" / "settings.json").read_text())
assert "env" not in settings
def test_adopt_no_agent_skips_spawn(host_env):
_, target = host_env
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn,
):
result = adopt_project(target, no_agent=True)
mock_spawn.assert_not_called()
assert result["agent_created"] is False
assert result["agent_home"] is None
def test_adopt_with_agent_spawns(host_env):
_, target = host_env
spawn_ok = {
"success": True,
"branch_name": "EXISTING_SITE",
"path": str(target / "src" / "existing_site" / "existing_site"),
"files_copied": 12,
"registry_updated": True,
"validation_issues": [],
}
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value=spawn_ok,
) as mock_spawn,
):
result = adopt_project(target, no_agent=False)
mock_spawn.assert_called_once()
assert result["agent_created"] is True
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
def test_adopt_registry_minted_before_spawn(host_env):
"""Registry-first invariant: registry file exists on disk before spawn_agent is called."""
_, target = host_env
seen = {}
def _check_registry_exists(**kwargs):
seen["registry_present"] = (target / "EXISTING-SITE_REGISTRY.json").exists()
return {
"success": True,
"branch_name": "EXISTING_SITE",
"path": kwargs["target_path"],
"files_copied": 1,
"registry_updated": True,
"validation_issues": [],
}
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent",
side_effect=_check_registry_exists,
),
):
adopt_project(target, no_agent=False)
assert seen["registry_present"] is True
# ---------------------------------------------------------------------------
# adopt_project — dry_run performs zero writes
# ---------------------------------------------------------------------------
def test_adopt_dry_run_writes_nothing(host_env):
_, target = host_env
before = sorted(p.relative_to(target) for p in target.rglob("*"))
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn:
result = adopt_project(target, no_agent=False, dry_run=True)
after = sorted(p.relative_to(target) for p in target.rglob("*"))
assert before == after
mock_spawn.assert_not_called()
assert result["dry_run"] is True
assert result["registry_id"] is None
assert result["agent_created"] is False
def test_adopt_dry_run_reports_planned_registry_and_agent(host_env):
_, target = host_env
with _no_home():
result = adopt_project(target, no_agent=False, dry_run=True)
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
assert "EXISTING-SITE_REGISTRY.json" in result["files"]
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
assert any("resident agent" in f for f in result["files"])
def test_adopt_dry_run_no_agent_reports_no_home(host_env):
_, target = host_env
with _no_home():
result = adopt_project(target, no_agent=True, dry_run=True)
assert result["agent_home"] is None
def test_adopt_dry_run_still_reports_gitignore_action(host_env):
_, target = host_env
with _no_home():
result = adopt_project(target, no_agent=True, dry_run=True)
assert result["gitignore_action"] == "created"
assert not (target / ".gitignore").exists()
# ---------------------------------------------------------------------------
# Module handle_command
# ---------------------------------------------------------------------------
def test_module_handles_not_mine():
from aipass.aipass.apps.modules.adopt import handle_command
assert handle_command("notmine", []) is False
def test_module_handles_help():
from aipass.aipass.apps.modules.adopt import handle_command
assert handle_command("adopt", ["--help"]) is True
def test_module_handles_no_args():
from aipass.aipass.apps.modules.adopt import handle_command
assert handle_command("adopt", []) is True
def test_module_rejects_unknown_option(host_env):
from aipass.aipass.apps.modules.adopt import handle_command
_, target = host_env
with patch("aipass.aipass.apps.modules.adopt.error") as mock_error:
handle_command("adopt", [str(target), "--bogus"])
mock_error.assert_called_once()
assert "Unknown option" in mock_error.call_args[0][0]
def test_module_adopt_by_absolute_path(host_env, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
):
handle_command("adopt", [str(target), "--no-agent"])
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
assert "Registry:" in printed
assert "EXISTING-SITE_REGISTRY.json" in printed
def test_module_adopt_by_bare_name(host_env, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
with (
_no_home(),
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
):
handle_command("adopt", ["existing-site", "--no-agent"])
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
assert "Registry:" in printed
assert "EXISTING-SITE_REGISTRY.json" in printed
def test_module_adopt_dry_run_reports_would_adopt(host_env, monkeypatch, capsys):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
with _no_home():
handle_command("adopt", ["existing-site", "--no-agent", "--dry-run"])
out = capsys.readouterr().out
assert "Would adopt" in out
assert not (target / "EXISTING-SITE_REGISTRY.json").exists()
def test_module_adopt_missing_target_no_host(tmp_path, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
monkeypatch.chdir(tmp_path)
with (
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
pytest.raises(SystemExit) as exc_info,
):
handle_command("adopt", ["nope"])
mock_error.assert_called_once()
assert "Not inside an AIPass installation" in mock_error.call_args[0][0]
assert exc_info.value.code == 1
def test_module_adopt_reports_refusal(host_env, monkeypatch):
from aipass.aipass.apps.modules.adopt import handle_command
host, target = host_env
monkeypatch.chdir(host)
(target / "EXISTING-SITE_REGISTRY.json").write_text("{}")
with (
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
pytest.raises(SystemExit) as exc_info,
):
handle_command("adopt", ["existing-site", "--no-agent"])
mock_error.assert_called_once()
assert "already adopted" in mock_error.call_args[0][0]
assert exc_info.value.code == 1
# ---------------------------------------------------------------------------
# aipass.py wiring
# ---------------------------------------------------------------------------
def test_aipass_public_commands_includes_adopt():
from aipass.aipass.apps.aipass import _PUBLIC_COMMANDS
assert "adopt" in _PUBLIC_COMMANDS
+60 -20
View File
@@ -20,7 +20,6 @@ from pathlib import Path
import pytest # pyright: ignore[reportMissingImports]
from aipass.aipass.apps.handlers.init import scaffold_content as sc
from aipass.aipass.apps.handlers.init.bootstrap import (
_merge_hooks_json,
_sanitize_name,
@@ -28,6 +27,7 @@ from aipass.aipass.apps.handlers.init.bootstrap import (
init_project,
update_project,
)
from aipass.aipass.shared import scaffold_content as sc
# ---------------------------------------------------------------------------
@@ -293,7 +293,7 @@ def test_init_project_settings_no_hooks(tmp_path, monkeypatch):
data = json.loads(settings_path.read_text(encoding="utf-8"))
assert "hooks" not in data, "Project settings should not contain hooks"
assert "env" in data
assert "env" not in data, "AIPASS_HOME is machine-local — belongs in settings.local.json"
assert "permissions" in data
@@ -547,9 +547,10 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
result = update_project(target)
assert (target / ".claude" / "settings.json").exists()
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap, hooks.json, settings, prep)
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap,
# hooks.json, settings.json, settings.local.json, prep)
if result["aipass_home"]:
assert len(result["updated_files"]) == 5
assert len(result["updated_files"]) == 6
else:
assert len(result["updated_files"]) == 2
assert len(result["already_current"]) >= 2
@@ -584,7 +585,7 @@ def test_init_project_returns_aipass_home(tmp_path):
def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch):
"""When AIPASS_HOME is detected, settings.json includes env.AIPASS_HOME."""
"""When AIPASS_HOME is detected, settings.local.json (not settings.json) includes env.AIPASS_HOME."""
monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False,
@@ -598,8 +599,10 @@ def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypat
pytest.skip("AIPASS_HOME not detectable in this environment")
settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8"))
assert "env" in settings
assert settings["env"]["AIPASS_HOME"] == result["aipass_home"]
assert "env" not in settings
local_settings = json.loads((target / ".claude" / "settings.local.json").read_text(encoding="utf-8"))
assert local_settings["env"]["AIPASS_HOME"] == result["aipass_home"]
def test_update_project_returns_aipass_home(tmp_path):
@@ -615,7 +618,7 @@ def test_update_project_returns_aipass_home(tmp_path):
def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
"""update_project injects AIPASS_HOME into settings.json if env section is absent."""
"""update_project recreates settings.local.json with AIPASS_HOME if missing."""
monkeypatch.setattr(
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
lambda _: False,
@@ -624,17 +627,15 @@ def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
target.mkdir()
init_project(target, project_name="addenv")
settings_path = target / ".claude" / "settings.json"
data = json.loads(settings_path.read_text(encoding="utf-8"))
data.pop("env", None)
settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
local_settings_path = target / ".claude" / "settings.local.json"
local_settings_path.unlink(missing_ok=True)
result = update_project(target)
if result["aipass_home"] is not None:
new_data = json.loads(settings_path.read_text(encoding="utf-8"))
new_data = json.loads(local_settings_path.read_text(encoding="utf-8"))
assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"]
assert str(settings_path) in result["updated_files"]
assert str(local_settings_path) in result["updated_files"]
# ---------------------------------------------------------------------------
@@ -1256,10 +1257,49 @@ def test_throwaway_path_allows_project():
assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp"))
def test_settings_omits_throwaway_aipass_home(tmp_path):
"""_claude_settings refuses to write AIPASS_HOME when it's a throwaway path."""
from aipass.aipass.apps.handlers.init.bootstrap import _claude_settings
def test_settings_omits_throwaway_aipass_home(tmp_path, monkeypatch):
"""init_project skips settings.local.json when detected AIPASS_HOME is a throwaway path."""
from aipass.aipass.apps.handlers.init import bootstrap
content = _claude_settings(str(tmp_path))
data = json.loads(content)
assert "AIPASS_HOME" not in data.get("env", {})
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: str(tmp_path))
target = tmp_path / "proj"
target.mkdir()
bootstrap.init_project(target, project_name="alpha")
assert not (target / ".claude" / "settings.local.json").exists()
# Directory/file-name fragments that .gitignore excludes from git tracking —
# mirrors scaffold_content.gitignore(). Tracked-file scans must skip these.
_GITIGNORED_PARTS = (".trinity", ".ai_mail.local", "logs", ".venv", "venv", ".git")
def test_minted_tracked_files_have_no_absolute_paths(tmp_path, monkeypatch):
"""No file init_project writes into a TRACKED path may contain the machine-local AIPASS_HOME.
Regression guard for the settings.json bug: AIPASS_HOME is an absolute,
machine-local path and must only ever land in gitignored *.local.json
files. This walks every minted file that would actually be committed
and greps it for the (fake) AIPASS_HOME value.
"""
from aipass.aipass.apps.handlers.init import bootstrap
fake_home = str(tmp_path / f"fake_aipass_home_{uuid.uuid4().hex}")
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: fake_home)
monkeypatch.setattr(bootstrap, "is_throwaway_path", lambda _: False)
target = tmp_path / "proj"
target.mkdir()
bootstrap.init_project(target, project_name="pathcheck")
for path in target.rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(target)
if any(part in _GITIGNORED_PARTS for part in rel.parts):
continue
if ".local." in rel.name:
continue
content = path.read_text(encoding="utf-8")
assert fake_home not in content, f"{rel} leaks machine-local AIPASS_HOME"
+22 -22
View File
@@ -183,11 +183,11 @@ def test_create_project_empty_template(host_env, monkeypatch):
with (
patch("subprocess.run", side_effect=_mock_git_run),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project",
"aipass.aipass.shared.project_home._enroll_project",
),
):
result = create_project("testproj", template="empty", no_agent=True)
@@ -208,11 +208,11 @@ def test_create_project_python_template(host_env, monkeypatch):
with (
patch("subprocess.run", side_effect=_mock_git_run),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project",
"aipass.aipass.shared.project_home._enroll_project",
),
):
result = create_project("pyapp", template="python", no_agent=True)
@@ -261,10 +261,10 @@ def test_create_project_cleans_up_on_failure(host_env, monkeypatch):
with (
patch("subprocess.run", side_effect=_fail_git),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
patch("aipass.aipass.shared.project_home._enroll_project"),
pytest.raises(RuntimeError, match="simulated failure"),
):
create_project("failproj", no_agent=True)
@@ -299,10 +299,10 @@ def test_create_project_registry_before_scaffold(host_env, monkeypatch):
side_effect=track_template,
),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
patch("aipass.aipass.shared.project_home._enroll_project"),
):
create_project("ordertest", no_agent=True)
@@ -399,10 +399,10 @@ def test_create_project_with_agent(host_env, monkeypatch):
with (
patch("subprocess.run", side_effect=_mock_git_run),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
patch("aipass.aipass.shared.project_home._enroll_project"),
patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value=spawn_ok,
@@ -426,10 +426,10 @@ def test_create_project_spawn_failure_cleans_up(host_env, monkeypatch):
with (
patch("subprocess.run", side_effect=_mock_git_run),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
patch("aipass.aipass.shared.project_home._enroll_project"),
patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value={"success": False, "error": "template missing"},
@@ -449,10 +449,10 @@ def test_create_project_no_agent_next_steps(host_env, monkeypatch):
with (
patch("subprocess.run", side_effect=_mock_git_run),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
patch("aipass.aipass.shared.project_home._enroll_project"),
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
):
handle_command("new", ["cosmtest", "--template", "empty", "--no-agent"])
@@ -466,10 +466,10 @@ def test_create_project_no_agent_flag(host_env, monkeypatch):
with (
patch("subprocess.run", side_effect=_mock_git_run),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
patch("aipass.aipass.shared.project_home._enroll_project"),
):
result = create_project("noagent", template="empty", no_agent=True)
@@ -645,10 +645,10 @@ def test_tty_auto_launches_agent(host_env, monkeypatch):
patch("subprocess.run", side_effect=_mock_git_run),
patch("builtins.input", return_value=""),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
patch("aipass.aipass.shared.project_home._enroll_project"),
patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value=spawn_ok,
@@ -680,10 +680,10 @@ def test_no_tty_skips_auto_launch(host_env, monkeypatch):
patch("subprocess.run", side_effect=_mock_git_run),
patch("builtins.input", return_value=""),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
patch("aipass.aipass.shared.project_home._enroll_project"),
patch(
"aipass.aipass.apps.handlers.new_project.spawn_agent",
return_value=spawn_ok,
@@ -708,10 +708,10 @@ def test_no_agent_skips_auto_launch(host_env, monkeypatch):
with (
patch("subprocess.run", side_effect=_mock_git_run),
patch(
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.shared.project_home._detect_aipass_home",
return_value=None,
),
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
patch("aipass.aipass.shared.project_home._enroll_project"),
patch("aipass.aipass.apps.modules.new_project.console"),
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
@@ -24,7 +24,9 @@ import sys
import aipass.aipass.shared.json_handler
import aipass.aipass.shared.json_ops
import aipass.aipass.shared.project_home
import aipass.aipass.shared.registry_discovery
import aipass.aipass.shared.scaffold_content
bad = []
for name in sorted(sys.modules):
@@ -429,7 +429,7 @@
},
"metadata": {
"description": "Template file tracking registry for ID-based updates",
"last_updated": "2026-07-19",
"last_updated": "2026-07-20",
"version": "1.0.0"
}
}