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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
+12
-6
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user