feat(aipass): aipass init template selector — empty-project default + stage gating (TDPLAN-0010, FPLAN-0295)

This commit is contained in:
AIOSAI
2026-07-01 08:09:30 -07:00
parent a2812abc90
commit 5a3d01efb1
4 changed files with 195 additions and 12 deletions
+12
View File
@@ -13,6 +13,18 @@ PyPI version — not the changelog header.
### Added
- **`aipass init` is now a template selector (TDPLAN-0010)** — `init` presents a
chooser with **`empty project`** at the top, pre-selected as the default
(creates just the project folder, no scaffold), and **`aipass_framework`**
below it (the full AIPass agent framework — the old always-on behavior, now
opt-in). Flag and positional forms both work: `aipass init --list` (branches
before the `--` catch-all) and `aipass init <template>`. The AIPass-specific
stages (8 spawn-first-agent / 9 ping-registry / 11 handoff / 12 init_report,
`AIPASS_SPECIFIC_STAGES`) and the `bootstrap.init_project()` scaffold are now
gated on the chosen template, so an empty project stays empty. In-product pip
hints in `init_flow.py` + `doctor.py` retuned to clone/`setup.sh`. 8 new
selector tests; 499 tests pass. (built by @aipass, FPLAN-0295, TDPLAN-0010)
- **Unified Telegram ↔ Claude Code bridge — CC-native session discovery
(DPLAN-0226)** — a Telegram message to a branch's bot now lands directly in
that branch's live Claude Code session, and the reply tails back out to
+14 -2
View File
@@ -399,11 +399,23 @@ def _check_services(verbose: bool = False) -> List[CheckResult]:
results.append(CheckResult("drone", GLYPH_PASS, detail, ""))
else:
results.append(
CheckResult("drone", GLYPH_FAIL, "exit non-zero", "Ensure aipass is installed: pip install -e .")
CheckResult(
"drone",
GLYPH_FAIL,
"exit non-zero",
"Ensure aipass is installed: clone the repo and run setup.sh",
)
)
except FileNotFoundError as exc:
logger.warning("[doctor] drone not found: %s", exc)
results.append(CheckResult("drone", GLYPH_FAIL, "not found", "Ensure aipass is installed: pip install -e ."))
results.append(
CheckResult(
"drone",
GLYPH_FAIL,
"not found",
"Ensure aipass is installed: clone the repo and run setup.sh",
)
)
except subprocess.TimeoutExpired as exc:
logger.warning("[doctor] drone systems timed out: %s", exc)
results.append(CheckResult("drone", GLYPH_WARN, "timed out", ""))
+49 -5
View File
@@ -92,6 +92,11 @@ CLI_CHOICES = ["claude", "codex", "other"]
FLAG_CHOICES = ["default", "skip-permissions"]
STYLE_CHOICES = ["building-my-own-project", "improving-aipass", "just-exploring"]
TEMPLATE_EMPTY = "empty project"
TEMPLATE_AIPASS = "aipass_framework"
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
AIPASS_SPECIFIC_STAGES = {8, 9, 11, 12}
# --- LOCAL JSON HELPERS ---
def _read_local_json() -> dict:
@@ -597,12 +602,12 @@ def stage_10_smoke_test(non_interactive: bool = False, dry_run: bool = False) ->
if drone_bin:
console.print(f"[green]✓[/green] drone: {drone_bin}")
else:
warning("drone not on PATH — run: pip install -e .")
warning("drone not on PATH — clone the repo and run setup.sh")
if aipass_bin:
console.print(f"[green]✓[/green] aipass: {aipass_bin}")
else:
warning("aipass not on PATH — run: pip install -e .")
warning("aipass not on PATH — clone the repo and run setup.sh")
_save_stage(10, {"drone": drone_bin, "aipass": aipass_bin}, dry_run=dry_run)
return {"drone": drone_bin, "aipass": aipass_bin}
@@ -775,6 +780,7 @@ def run_init(
style: str | None = None,
no_docker: bool = False,
dry_run: bool = False,
template: str | None = None,
) -> int:
"""Run the 12-stage init flow. Returns 0 on success."""
# Pre-flight: refuse to run inside existing projects or agent dirs
@@ -783,9 +789,20 @@ def run_init(
console.print(f"[red]✗[/red] {err}")
return 1
# Ensure scaffold exists (creates registry, .aipass, etc. if missing)
# Template selection — before scaffold
if template is None:
if non_interactive:
template = TEMPLATE_EMPTY
else:
template = _choose(
"Choose a project template:",
TEMPLATE_CHOICES,
default=TEMPLATE_EMPTY,
)
# Ensure scaffold exists — only for aipass_framework
cwd = Path.cwd()
if not list(cwd.glob("*_REGISTRY.json")):
if template == TEMPLATE_AIPASS and not list(cwd.glob("*_REGISTRY.json")):
from aipass.aipass.apps.handlers.init.bootstrap import init_project
if not dry_run:
@@ -804,7 +821,7 @@ def run_init(
if last_done > 0:
warning(f"Resuming from stage {last_done + 1}...")
accumulated: Dict[str, Any] = {}
accumulated: Dict[str, Any] = {"template": template}
stage_fns = [
(1, lambda: stage_1_welcome(dry_run=dry_run)),
@@ -834,6 +851,9 @@ def run_init(
for stage_num, fn in stage_fns:
if stage_num <= last_done:
continue
if stage_num in AIPASS_SPECIFIC_STAGES and template != TEMPLATE_AIPASS:
logger.info("[init_flow] skipping stage %d (not aipass_framework)", stage_num)
continue
try:
result = fn() or {}
accumulated.update(result)
@@ -846,6 +866,11 @@ def run_init(
warning(f"Stage {stage_num} error: {exc} — continuing.")
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
if template != TEMPLATE_AIPASS:
console.print()
console.print("[green]✓[/green] Project initialized.")
console.print("[dim]Run 'aipass init agent <name>' to add an agent.[/dim]")
return 0
@@ -878,8 +903,10 @@ def print_help() -> None:
console.print(" [green]aipass init run --non-interactive[/green] [dim]# CI/headless[/dim]")
console.print(" [green]aipass init run --name YourName[/green] [dim]# pre-fill name[/dim]")
console.print(" [green]aipass init run --cli claude[/green] [dim]# pre-fill CLI[/dim]")
console.print(" [green]aipass init run --template <name>[/green] [dim]# select template[/dim]")
console.print(" [green]aipass init run --no-docker[/green] [dim]# skip docker offer[/dim]")
console.print(" [green]aipass init run --dry-run[/green] [dim]# walk all stages, no writes[/dim]")
console.print(" [green]aipass init --list[/green] [dim]# list available templates[/dim]")
console.print()
console.print("[yellow]STAGES:[/yellow] 12 stages, each saved — resume on ctrl-C")
console.print()
@@ -1008,6 +1035,15 @@ def handle_command(command: str, args: list[str]) -> bool:
sys.exit(_handle_init_update(args[1:]))
return True
if args[0] == "--list":
console.print()
console.print("[bold cyan]Available project templates:[/bold cyan]")
for t in TEMPLATE_CHOICES:
marker = " [dim](default)[/dim]" if t == TEMPLATE_EMPTY else ""
console.print(f" • {t}{marker}")
console.print()
return True
if args[0] == "run" or args[0].startswith("--"):
run_args = args[1:] if args[0] == "run" else args
non_interactive = "--non-interactive" in run_args
@@ -1022,6 +1058,7 @@ def handle_command(command: str, args: list[str]) -> bool:
name = _flag_value("--name")
cli = _flag_value("--cli")
style = _flag_value("--style")
template = _flag_value("--template")
no_docker = "--no-docker" in run_args
dry_run = "--dry-run" in run_args
@@ -1032,6 +1069,7 @@ def handle_command(command: str, args: list[str]) -> bool:
style=style,
no_docker=no_docker,
dry_run=dry_run,
template=template,
)
json_handler.log_operation(
"init_run",
@@ -1040,6 +1078,12 @@ def handle_command(command: str, args: list[str]) -> bool:
sys.exit(result)
return True
# Template name as positional arg
if args[0] in TEMPLATE_CHOICES:
result = run_init(template=args[0])
sys.exit(result)
return True
# Positional args = target path and/or project name for scaffold
err = _preflight_check()
if err:
+120 -5
View File
@@ -16,6 +16,10 @@ from unittest.mock import MagicMock, patch
import pytest
from aipass.aipass.apps.modules.init_flow import (
AIPASS_SPECIFIC_STAGES,
TEMPLATE_AIPASS,
TEMPLATE_CHOICES,
TEMPLATE_EMPTY,
TOTAL_STAGES,
_get_last_completed_stage,
_get_setup_progress,
@@ -287,7 +291,7 @@ class TestRunInit:
assert result == 0
def test_non_interactive_runs_all_stages(self, tmp_local_json) -> None:
"""non_interactive=True runs all 12 stages from fresh state."""
"""non_interactive=True with aipass_framework runs all 12 stages."""
patches = self._patch_all_stages()
mocks = []
ctx = __import__("contextlib").ExitStack()
@@ -296,7 +300,7 @@ class TestRunInit:
with ctx:
with patch("aipass.aipass.apps.modules.init_flow.json_handler"):
with patch("aipass.aipass.apps.modules.init_flow.console"):
result = run_init(non_interactive=True)
result = run_init(non_interactive=True, template=TEMPLATE_AIPASS)
assert result == 0
def test_keyboard_interrupt_pauses_gracefully(self, tmp_local_json) -> None:
@@ -304,7 +308,7 @@ class TestRunInit:
with patch("aipass.aipass.apps.modules.init_flow.stage_1_welcome", side_effect=KeyboardInterrupt):
with patch("aipass.aipass.apps.modules.init_flow.console"):
with patch("aipass.aipass.apps.modules.init_flow.warning"):
result = run_init(non_interactive=False)
result = run_init(non_interactive=False, template=TEMPLATE_EMPTY)
assert result == 0
def test_stage_error_continues(self, tmp_local_json) -> None:
@@ -336,7 +340,7 @@ class TestRunInit:
warning=MagicMock(),
console=MagicMock(),
):
result = run_init(non_interactive=True)
result = run_init(non_interactive=True, template=TEMPLATE_AIPASS)
assert result == 0
def test_resumes_from_last_completed(self, tmp_local_json_with_progress: Path) -> None:
@@ -361,7 +365,7 @@ class TestRunInit:
warning=MagicMock(),
console=MagicMock(),
):
run_init(non_interactive=True)
run_init(non_interactive=True, template=TEMPLATE_AIPASS)
stage_1_mock.assert_not_called()
stage_4_mock.assert_called_once()
@@ -719,3 +723,114 @@ class TestInitUpdateRegistrySync:
):
rc = _handle_init_update([str(tmp_path)])
assert rc == 0
# =============================================================================
# TestTemplateSelector
# =============================================================================
class TestTemplateSelector:
"""Tests for the template selector in aipass init."""
@staticmethod
def _stage_patches():
"""Return patches for all 12 stage functions as no-ops."""
stage_names = [
"stage_1_welcome",
"stage_2_system_detect",
"stage_3_doctor",
"stage_4_user_profile",
"stage_5_style_questions",
"stage_6_tool_choice",
"stage_7_docker_offer",
"stage_8_first_agent",
"stage_9_ping_sweep",
"stage_10_smoke_test",
"stage_11_handoff",
"stage_12_done",
]
return {name: MagicMock(return_value={}) for name in stage_names}
def test_empty_project_default_skips_scaffold(self, tmp_local_json) -> None:
"""empty project (default) = no scaffold, stages 8,9,11,12 skipped."""
mocks = self._stage_patches()
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
result = run_init(non_interactive=True, template=TEMPLATE_EMPTY)
assert result == 0
for name in (
"stage_1_welcome",
"stage_2_system_detect",
"stage_3_doctor",
"stage_4_user_profile",
"stage_5_style_questions",
"stage_6_tool_choice",
"stage_7_docker_offer",
"stage_10_smoke_test",
):
assert mocks[name].called, f"{name} should have been called"
for name in ("stage_8_first_agent", "stage_9_ping_sweep", "stage_11_handoff", "stage_12_done"):
assert not mocks[name].called, f"{name} should NOT have been called"
def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None:
"""aipass_framework = full scaffold + all 12 stages."""
mocks = self._stage_patches()
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
with patch(
"aipass.aipass.apps.handlers.init.bootstrap.init_project",
return_value={},
):
result = run_init(non_interactive=True, template=TEMPLATE_AIPASS)
assert result == 0
for name in mocks:
assert mocks[name].called, f"{name} should have been called"
def test_list_flag_shows_catalog(self) -> None:
"""aipass init --list shows the catalog (not swallowed into run)."""
with patch(f"{_MOD}.console") as mock_console:
result = handle_command("init", ["--list"])
assert result is True
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
for t in TEMPLATE_CHOICES:
assert t in printed
def test_template_flag_form_works(self, tmp_local_json) -> None:
"""aipass init run --template aipass_framework passes template to run_init."""
with patch(f"{_MOD}.run_init", return_value=0) as mock_run:
with pytest.raises(SystemExit):
handle_command("init", ["run", "--template", TEMPLATE_AIPASS])
mock_run.assert_called_once()
_, kwargs = mock_run.call_args
assert kwargs["template"] == TEMPLATE_AIPASS
def test_positional_template_routes_to_run_init(self, tmp_local_json) -> None:
"""aipass init aipass_framework routes to run_init with template."""
with patch(f"{_MOD}.run_init", return_value=0) as mock_run:
with pytest.raises(SystemExit):
handle_command("init", [TEMPLATE_AIPASS])
mock_run.assert_called_once()
_, kwargs = mock_run.call_args
assert kwargs["template"] == TEMPLATE_AIPASS
def test_positional_path_still_works(self, tmp_local_json) -> None:
"""Non-template positional args still route to scaffold."""
with patch(f"{_MOD}._preflight_check", return_value=None):
with patch(f"{_MOD}._handle_init_scaffold", return_value=0) as mock_scaffold:
with pytest.raises(SystemExit):
handle_command("init", ["/tmp/test-proj"])
mock_scaffold.assert_called_once_with(["/tmp/test-proj"])
def test_pip_hints_say_clone(self, tmp_local_json) -> None:
"""in-product hints say clone/setup.sh, not pip."""
with patch(f"{_MOD}.console"):
with patch(f"{_MOD}.warning") as mock_warn:
with patch(f"{_MOD}.shutil.which", return_value=None):
stage_10_smoke_test()
for call in mock_warn.call_args_list:
msg = call[0][0].lower()
assert "setup.sh" in msg
assert "pip" not in msg
def test_aipass_specific_stages_constant(self) -> None:
"""AIPASS_SPECIFIC_STAGES contains exactly {8, 9, 11, 12}."""
assert AIPASS_SPECIFIC_STAGES == {8, 9, 11, 12}