feat(cli): migrate aipass init from devpulse prototype (#48)
Restructure init_project.py into CLI's 3-layer architecture: - Handler: apps/handlers/init/bootstrap.py (pure Python business logic) - Module: apps/modules/init_project.py (thin orchestration) - Wiring: cli.py routes `aipass init` via _handle_aipass() Command: drone @cli aipass init [target_dir] [project_name] FPLAN-0041 Co-authored-by: @cli <cli@aipass>
This commit is contained in:
@@ -332,6 +332,23 @@ def show_version():
|
||||
CONSOLE.print("CLI v0.2.0")
|
||||
|
||||
|
||||
def _handle_aipass(args):
|
||||
"""Route aipass subcommands."""
|
||||
if not args:
|
||||
error("Missing subcommand", suggestion="Try: drone @cli aipass init")
|
||||
sys.exit(1)
|
||||
|
||||
subcmd = args[0]
|
||||
sub_args = args[1:]
|
||||
|
||||
from aipass.cli.apps.modules.init_project import handle_command
|
||||
if handle_command(subcmd, sub_args):
|
||||
return
|
||||
|
||||
error(f"Unknown aipass subcommand: {subcmd}", suggestion="Try: drone @cli aipass init")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI branch entry point - shows available services"""
|
||||
|
||||
@@ -350,8 +367,17 @@ def main():
|
||||
print_help()
|
||||
return
|
||||
|
||||
# Default behavior for other commands
|
||||
print_help()
|
||||
# Route subcommands
|
||||
command = sys.argv[1]
|
||||
cmd_args = sys.argv[2:] if len(sys.argv) > 2 else []
|
||||
|
||||
if command == "aipass":
|
||||
_handle_aipass(cmd_args)
|
||||
return
|
||||
|
||||
# Unknown command
|
||||
error(f"Unknown command: {command}", suggestion="Run 'drone @cli --help' for usage")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: bootstrap.py
|
||||
# Description: Init handler — bootstrap an AIPass project in any directory
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-14
|
||||
# Modified: 2026-03-14
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Init Bootstrap Handler - PRIVATE implementation
|
||||
|
||||
Business logic for `aipass init`. Creates the project scaffold:
|
||||
1. {NAME}_REGISTRY.json — project registry with UUID
|
||||
2. .trinity/passport.json — project identity
|
||||
3. .trinity/local.json — local context (empty)
|
||||
4. .trinity/observations.json — observations (empty)
|
||||
5. .aipass/aipass_local_prompt.md — local prompt
|
||||
6. AIPASS.md — project prompt
|
||||
|
||||
RULES:
|
||||
- Pure Python only (no module/prax/cli imports)
|
||||
- Returns dict, raises exceptions on errors
|
||||
- No hardcoded paths
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _sanitize_name(raw: str) -> str:
|
||||
"""Sanitize a project name for use in filenames.
|
||||
|
||||
Replaces non-alphanumeric characters (except underscore/hyphen) with
|
||||
underscores and strips leading/trailing underscores.
|
||||
"""
|
||||
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
|
||||
|
||||
|
||||
def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
"""Initialize an AIPass project in the target directory.
|
||||
|
||||
Args:
|
||||
target: Directory to initialize
|
||||
project_name: Name for the registry (defaults to directory name)
|
||||
|
||||
Returns:
|
||||
dict with registry_id, registry_file, project_name, target, created_files
|
||||
|
||||
Raises:
|
||||
ValueError: If project name is empty after sanitization
|
||||
FileExistsError: If passport or registry already exists
|
||||
"""
|
||||
target = target.resolve()
|
||||
if not target.exists():
|
||||
target.mkdir(parents=True)
|
||||
|
||||
raw_name = project_name or target.name
|
||||
name = _sanitize_name(raw_name)
|
||||
if not name:
|
||||
raise ValueError(
|
||||
f"Cannot derive project name from '{raw_name}'. "
|
||||
"Pass a project name explicitly."
|
||||
)
|
||||
|
||||
registry_id = str(uuid.uuid4())
|
||||
today = date.today().isoformat()
|
||||
created = []
|
||||
|
||||
# 1. Registry
|
||||
registry_filename = f"{name}_REGISTRY.json"
|
||||
registry_path = target / registry_filename
|
||||
if registry_path.exists():
|
||||
raise FileExistsError(f"Registry already exists: {registry_path}")
|
||||
|
||||
registry_data = {
|
||||
"metadata": {
|
||||
"id": registry_id,
|
||||
"name": name,
|
||||
"version": "1.0.0",
|
||||
"created": today,
|
||||
"last_updated": today,
|
||||
"total_branches": 0,
|
||||
},
|
||||
"branches": [],
|
||||
}
|
||||
registry_path.write_text(
|
||||
json.dumps(registry_data, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(str(registry_path))
|
||||
|
||||
# 2. .trinity/
|
||||
trinity_dir = target / ".trinity"
|
||||
trinity_dir.mkdir(exist_ok=True)
|
||||
|
||||
passport = {
|
||||
"document_metadata": {
|
||||
"document_type": "project_identity",
|
||||
"document_name": f"{name}.PASSPORT",
|
||||
"version": "1.0.0",
|
||||
"created": today,
|
||||
"last_updated": today,
|
||||
},
|
||||
"identity": {
|
||||
"project_name": name,
|
||||
"role": "project_root",
|
||||
"purpose": "",
|
||||
},
|
||||
"citizenship": {
|
||||
"registered": True,
|
||||
"registry_id": registry_id,
|
||||
"registry_name": name,
|
||||
},
|
||||
}
|
||||
passport_path = trinity_dir / "passport.json"
|
||||
if passport_path.exists():
|
||||
raise FileExistsError(
|
||||
f"Passport already exists: {passport_path}. "
|
||||
"Remove .trinity/passport.json to re-initialize."
|
||||
)
|
||||
passport_path.write_text(
|
||||
json.dumps(passport, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(str(passport_path))
|
||||
|
||||
# local.json + observations.json
|
||||
for filename in ("local.json", "observations.json"):
|
||||
p = trinity_dir / filename
|
||||
if not p.exists():
|
||||
p.write_text("{}\n", encoding="utf-8")
|
||||
created.append(str(p))
|
||||
|
||||
# 3. .aipass/
|
||||
aipass_dir = target / ".aipass"
|
||||
aipass_dir.mkdir(exist_ok=True)
|
||||
|
||||
prompt_path = aipass_dir / "aipass_local_prompt.md"
|
||||
if not prompt_path.exists():
|
||||
prompt_path.write_text(
|
||||
f"# {name} — Local Prompt\n\nInjected every turn. Add project-specific context here.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(str(prompt_path))
|
||||
|
||||
# 4. AIPASS.md
|
||||
aipass_md = target / "AIPASS.md"
|
||||
if not aipass_md.exists():
|
||||
aipass_md.write_text(
|
||||
"# AIPass — Project Prompt\n\n"
|
||||
"Project-level instructions. Persists in context for the entire conversation.\n\n"
|
||||
"## Startup\n\n"
|
||||
"On any greeting, silently read these files from CWD and run the commands "
|
||||
"— no narration, no announcing steps. Just do it and respond with the status.\n\n"
|
||||
"**Read:** `.trinity/passport.json`, `.trinity/local.json`, "
|
||||
"`.trinity/observations.json`, `README.md`\n"
|
||||
"**Run:** `git status`\n\n"
|
||||
"## Memories\n\n"
|
||||
"Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. "
|
||||
"If compaction hits before you save, it's gone.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(str(aipass_md))
|
||||
|
||||
return {
|
||||
"registry_id": registry_id,
|
||||
"registry_file": registry_filename,
|
||||
"project_name": name,
|
||||
"target": str(target),
|
||||
"created_files": created,
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: init_project.py
|
||||
# Description: Init Project Module — orchestration layer for aipass init
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-14
|
||||
# Modified: 2026-03-14
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Init Project Module - PUBLIC API
|
||||
|
||||
Thin orchestration module for the `aipass init` command.
|
||||
Routes to the init bootstrap handler for business logic.
|
||||
|
||||
Usage:
|
||||
drone @cli aipass init [target_dir] [project_name]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.cli.apps.handlers.init.bootstrap import init_project
|
||||
from aipass.cli.apps.modules.display import console, success, error, header
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODULE PATTERN FUNCTIONS (SEED compliant)
|
||||
# =============================================================================
|
||||
|
||||
def print_introspection():
|
||||
"""Display module info and connected handlers."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Init Project Module[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Bootstrap an AIPass project in any directory[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print()
|
||||
console.print(" [cyan]handlers/init/[/cyan]")
|
||||
console.print(" [dim]- bootstrap.py[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[dim]Run 'drone @cli aipass init --help' for usage[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Display help for the init command."""
|
||||
console.print()
|
||||
header("aipass init - Project Bootstrap")
|
||||
console.print("[dim]Initialize an AIPass project in any directory[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @cli aipass init[/dim] Initialize in current directory")
|
||||
console.print(" [dim]drone @cli aipass init /path/to/dir[/dim] Initialize in target directory")
|
||||
console.print(" [dim]drone @cli aipass init /path/to/dir MyProj[/dim] Initialize with custom name")
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]WHAT IT CREATES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]1.[/green] {NAME}_REGISTRY.json Project registry with UUID")
|
||||
console.print(" [green]2.[/green] .trinity/passport.json Project identity")
|
||||
console.print(" [green]3.[/green] .trinity/local.json Local context (empty)")
|
||||
console.print(" [green]4.[/green] .trinity/observations.json Observations (empty)")
|
||||
console.print(" [green]5.[/green] .aipass/aipass_local_prompt.md Local prompt")
|
||||
console.print(" [green]6.[/green] AIPASS.md Project prompt")
|
||||
console.print()
|
||||
|
||||
console.print("[dim]Commands: init, init --help[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""Handle 'init' command.
|
||||
|
||||
Args:
|
||||
command: The subcommand string (e.g. "init")
|
||||
args: Remaining positional arguments after the subcommand
|
||||
|
||||
Returns:
|
||||
True if the command was handled, False otherwise
|
||||
"""
|
||||
if command != "init":
|
||||
return False
|
||||
|
||||
# Handle help flag
|
||||
if args and args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
# Parse positional args: [target_dir] [project_name]
|
||||
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd())
|
||||
target = Path(args[0]) if args else Path(caller_cwd)
|
||||
project_name = args[1] if len(args) > 1 else None
|
||||
|
||||
try:
|
||||
result = init_project(target, project_name)
|
||||
except ValueError as exc:
|
||||
error(str(exc), suggestion="Pass a project name explicitly")
|
||||
sys.exit(1)
|
||||
except FileExistsError as exc:
|
||||
error(str(exc), suggestion="Remove the existing file to re-initialize")
|
||||
sys.exit(1)
|
||||
except OSError as exc:
|
||||
error(f"Filesystem error: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
# Display results
|
||||
header("Project Initialized")
|
||||
console.print(f" [bold]{result['project_name']}[/bold]")
|
||||
console.print()
|
||||
success(
|
||||
f"Created {len(result['created_files'])} files",
|
||||
registry=f"{result['registry_file']} (id: {result['registry_id'][:8]}...)",
|
||||
target=result["target"],
|
||||
)
|
||||
console.print()
|
||||
console.print("[dim]Files created:[/dim]")
|
||||
for f in result["created_files"]:
|
||||
console.print(f" [dim]{f}[/dim]")
|
||||
console.print()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODULE EXPORTS
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
"handle_command",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENTRY POINT (SEED pattern)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
if len(sys.argv) == 1:
|
||||
print_introspection()
|
||||
sys.exit(0)
|
||||
|
||||
if sys.argv[1] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
command = sys.argv[1]
|
||||
cmd_args = sys.argv[2:] if len(sys.argv) > 2 else []
|
||||
|
||||
if handle_command(command, cmd_args):
|
||||
sys.exit(0)
|
||||
else:
|
||||
console.print(f"[red]Unknown command: {command}[/red]")
|
||||
console.print("[dim]Run 'python3 init_project.py --help' for usage[/dim]")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user