diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c23d9fe..7d6f0876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/projects/README.md b/projects/README.md index 2465245a..e600dbef 100644 --- a/projects/README.md +++ b/projects/README.md @@ -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 diff --git a/src/aipass/aipass/README.md b/src/aipass/aipass/README.md index 898c6ed4..dcac76da 100644 --- a/src/aipass/aipass/README.md +++ b/src/aipass/aipass/README.md @@ -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 ` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent | | `aipass new --template python` | Create with Python template (pyproject + src/) | | `aipass new --no-agent` | Create without resident agent | +| `aipass adopt ` | Turn an existing `projects/` directory into a full project — additive scaffold only | +| `aipass adopt --no-agent` | Adopt without a resident agent | +| `aipass adopt --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 ` | Remove a project from the trust registry | | `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) | diff --git a/src/aipass/aipass/apps/aipass.py b/src/aipass/aipass/apps/aipass.py index 0f22bbd5..81468771 100644 --- a/src/aipass/aipass/apps/aipass.py +++ b/src/aipass/aipass/apps/aipass.py @@ -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 [/green] [dim]Create a project inside AIPass[/dim]") + console.print( + " [green]adopt [/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() diff --git a/src/aipass/aipass/apps/handlers/init/__init__.py b/src/aipass/aipass/apps/handlers/init/__init__.py index 4074df4b..98c2ce96 100644 --- a/src/aipass/aipass/apps/handlers/init/__init__.py +++ b/src/aipass/aipass/apps/handlers/init/__init__.py @@ -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, diff --git a/src/aipass/aipass/apps/handlers/init/bootstrap.py b/src/aipass/aipass/apps/handlers/init/bootstrap.py index 7452ef27..8a7464c8 100644 --- a/src/aipass/aipass/apps/handlers/init/bootstrap.py +++ b/src/aipass/aipass/apps/handlers/init/bootstrap.py @@ -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 ``/projects/`` — 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() diff --git a/src/aipass/aipass/apps/handlers/new_project/__init__.py b/src/aipass/aipass/apps/handlers/new_project/__init__.py index 3fc48b94..25514b0f 100644 --- a/src/aipass/aipass/apps/handlers/new_project/__init__.py +++ b/src/aipass/aipass/apps/handlers/new_project/__init__.py @@ -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) diff --git a/src/aipass/aipass/apps/handlers/new_project/adopt.py b/src/aipass/aipass/apps/handlers/new_project/adopt.py new file mode 100644 index 00000000..87cd0700 --- /dev/null +++ b/src/aipass/aipass/apps/handlers/new_project/adopt.py @@ -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 +/projects/ 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 /projects/ as a full AIPass project. + + Args: + target: Existing directory to adopt — must be /projects/. + 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 /projects/. `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, + } diff --git a/src/aipass/aipass/apps/modules/adopt.py b/src/aipass/aipass/apps/modules/adopt.py new file mode 100644 index 00000000..2a47f14f --- /dev/null +++ b/src/aipass/aipass/apps/modules/adopt.py @@ -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 [--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 [/green] [dim]# Adopt /projects/[/dim]") + console.print(" [green]aipass adopt [/green] [dim]# Adopt by relative/absolute path[/dim]") + console.print(" [green]aipass adopt --no-agent[/green] [dim]# Skip resident agent[/dim]") + console.print(" [green]aipass adopt --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 /projects/.") + 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 diff --git a/src/aipass/aipass/shared/project_home.py b/src/aipass/aipass/shared/project_home.py new file mode 100644 index 00000000..2ea1832b --- /dev/null +++ b/src/aipass/aipass/shared/project_home.py @@ -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 ``/projects/`` — 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 diff --git a/src/aipass/aipass/apps/handlers/init/scaffold_content.py b/src/aipass/aipass/shared/scaffold_content.py similarity index 96% rename from src/aipass/aipass/apps/handlers/init/scaffold_content.py rename to src/aipass/aipass/shared/scaffold_content.py index e423bdb0..9584ff87 100644 --- a/src/aipass/aipass/apps/handlers/init/scaffold_content.py +++ b/src/aipass/aipass/shared/scaffold_content.py @@ -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" ) diff --git a/src/aipass/aipass/tests/test_adopt.py b/src/aipass/aipass/tests/test_adopt.py new file mode 100644 index 00000000..43eaef3e --- /dev/null +++ b/src/aipass/aipass/tests/test_adopt.py @@ -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("\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 /projects/"): + 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 diff --git a/src/aipass/aipass/tests/test_bootstrap.py b/src/aipass/aipass/tests/test_bootstrap.py index 8d793c66..ebcc77cc 100644 --- a/src/aipass/aipass/tests/test_bootstrap.py +++ b/src/aipass/aipass/tests/test_bootstrap.py @@ -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" diff --git a/src/aipass/aipass/tests/test_new_project.py b/src/aipass/aipass/tests/test_new_project.py index ab4edc90..9bfe275c 100644 --- a/src/aipass/aipass/tests/test_new_project.py +++ b/src/aipass/aipass/tests/test_new_project.py @@ -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, diff --git a/src/aipass/aipass/tests/test_shared_bootstrap_safety.py b/src/aipass/aipass/tests/test_shared_bootstrap_safety.py index 5a0928b4..cc7d0645 100644 --- a/src/aipass/aipass/tests/test_shared_bootstrap_safety.py +++ b/src/aipass/aipass/tests/test_shared_bootstrap_safety.py @@ -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): diff --git a/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json b/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json index 7dc05fe8..f4bf3c40 100644 --- a/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json @@ -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" } }