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 ### 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 - **Unified Telegram ↔ Claude Code bridge — CC-native session discovery
(DPLAN-0226)** — a Telegram message to a branch's bot now lands directly in (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 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, "")) results.append(CheckResult("drone", GLYPH_PASS, detail, ""))
else: else:
results.append( 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: except FileNotFoundError as exc:
logger.warning("[doctor] drone not found: %s", 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: except subprocess.TimeoutExpired as exc:
logger.warning("[doctor] drone systems timed out: %s", exc) logger.warning("[doctor] drone systems timed out: %s", exc)
results.append(CheckResult("drone", GLYPH_WARN, "timed out", "")) 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"] FLAG_CHOICES = ["default", "skip-permissions"]
STYLE_CHOICES = ["building-my-own-project", "improving-aipass", "just-exploring"] 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 --- # --- LOCAL JSON HELPERS ---
def _read_local_json() -> dict: 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: if drone_bin:
console.print(f"[green]✓[/green] drone: {drone_bin}") console.print(f"[green]✓[/green] drone: {drone_bin}")
else: 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: if aipass_bin:
console.print(f"[green]✓[/green] aipass: {aipass_bin}") console.print(f"[green]✓[/green] aipass: {aipass_bin}")
else: 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) _save_stage(10, {"drone": drone_bin, "aipass": aipass_bin}, dry_run=dry_run)
return {"drone": drone_bin, "aipass": aipass_bin} return {"drone": drone_bin, "aipass": aipass_bin}
@@ -775,6 +780,7 @@ def run_init(
style: str | None = None, style: str | None = None,
no_docker: bool = False, no_docker: bool = False,
dry_run: bool = False, dry_run: bool = False,
template: str | None = None,
) -> int: ) -> int:
"""Run the 12-stage init flow. Returns 0 on success.""" """Run the 12-stage init flow. Returns 0 on success."""
# Pre-flight: refuse to run inside existing projects or agent dirs # Pre-flight: refuse to run inside existing projects or agent dirs
@@ -783,9 +789,20 @@ def run_init(
console.print(f"[red]✗[/red] {err}") console.print(f"[red]✗[/red] {err}")
return 1 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() 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 from aipass.aipass.apps.handlers.init.bootstrap import init_project
if not dry_run: if not dry_run:
@@ -804,7 +821,7 @@ def run_init(
if last_done > 0: if last_done > 0:
warning(f"Resuming from stage {last_done + 1}...") warning(f"Resuming from stage {last_done + 1}...")
accumulated: Dict[str, Any] = {} accumulated: Dict[str, Any] = {"template": template}
stage_fns = [ stage_fns = [
(1, lambda: stage_1_welcome(dry_run=dry_run)), (1, lambda: stage_1_welcome(dry_run=dry_run)),
@@ -834,6 +851,9 @@ def run_init(
for stage_num, fn in stage_fns: for stage_num, fn in stage_fns:
if stage_num <= last_done: if stage_num <= last_done:
continue 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: try:
result = fn() or {} result = fn() or {}
accumulated.update(result) accumulated.update(result)
@@ -846,6 +866,11 @@ def run_init(
warning(f"Stage {stage_num} error: {exc} — continuing.") warning(f"Stage {stage_num} error: {exc} — continuing.")
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run) _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 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 --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 --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 --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 --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 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()
console.print("[yellow]STAGES:[/yellow] 12 stages, each saved — resume on ctrl-C") console.print("[yellow]STAGES:[/yellow] 12 stages, each saved — resume on ctrl-C")
console.print() console.print()
@@ -1008,6 +1035,15 @@ def handle_command(command: str, args: list[str]) -> bool:
sys.exit(_handle_init_update(args[1:])) sys.exit(_handle_init_update(args[1:]))
return True 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("--"): if args[0] == "run" or args[0].startswith("--"):
run_args = args[1:] if args[0] == "run" else args run_args = args[1:] if args[0] == "run" else args
non_interactive = "--non-interactive" in run_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") name = _flag_value("--name")
cli = _flag_value("--cli") cli = _flag_value("--cli")
style = _flag_value("--style") style = _flag_value("--style")
template = _flag_value("--template")
no_docker = "--no-docker" in run_args no_docker = "--no-docker" in run_args
dry_run = "--dry-run" 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, style=style,
no_docker=no_docker, no_docker=no_docker,
dry_run=dry_run, dry_run=dry_run,
template=template,
) )
json_handler.log_operation( json_handler.log_operation(
"init_run", "init_run",
@@ -1040,6 +1078,12 @@ def handle_command(command: str, args: list[str]) -> bool:
sys.exit(result) sys.exit(result)
return True 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 # Positional args = target path and/or project name for scaffold
err = _preflight_check() err = _preflight_check()
if err: if err:
+120 -5
View File
@@ -16,6 +16,10 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from aipass.aipass.apps.modules.init_flow import ( from aipass.aipass.apps.modules.init_flow import (
AIPASS_SPECIFIC_STAGES,
TEMPLATE_AIPASS,
TEMPLATE_CHOICES,
TEMPLATE_EMPTY,
TOTAL_STAGES, TOTAL_STAGES,
_get_last_completed_stage, _get_last_completed_stage,
_get_setup_progress, _get_setup_progress,
@@ -287,7 +291,7 @@ class TestRunInit:
assert result == 0 assert result == 0
def test_non_interactive_runs_all_stages(self, tmp_local_json) -> None: 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() patches = self._patch_all_stages()
mocks = [] mocks = []
ctx = __import__("contextlib").ExitStack() ctx = __import__("contextlib").ExitStack()
@@ -296,7 +300,7 @@ class TestRunInit:
with ctx: with ctx:
with patch("aipass.aipass.apps.modules.init_flow.json_handler"): with patch("aipass.aipass.apps.modules.init_flow.json_handler"):
with patch("aipass.aipass.apps.modules.init_flow.console"): 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 assert result == 0
def test_keyboard_interrupt_pauses_gracefully(self, tmp_local_json) -> None: 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.stage_1_welcome", side_effect=KeyboardInterrupt):
with patch("aipass.aipass.apps.modules.init_flow.console"): with patch("aipass.aipass.apps.modules.init_flow.console"):
with patch("aipass.aipass.apps.modules.init_flow.warning"): 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 assert result == 0
def test_stage_error_continues(self, tmp_local_json) -> None: def test_stage_error_continues(self, tmp_local_json) -> None:
@@ -336,7 +340,7 @@ class TestRunInit:
warning=MagicMock(), warning=MagicMock(),
console=MagicMock(), console=MagicMock(),
): ):
result = run_init(non_interactive=True) result = run_init(non_interactive=True, template=TEMPLATE_AIPASS)
assert result == 0 assert result == 0
def test_resumes_from_last_completed(self, tmp_local_json_with_progress: Path) -> None: def test_resumes_from_last_completed(self, tmp_local_json_with_progress: Path) -> None:
@@ -361,7 +365,7 @@ class TestRunInit:
warning=MagicMock(), warning=MagicMock(),
console=MagicMock(), console=MagicMock(),
): ):
run_init(non_interactive=True) run_init(non_interactive=True, template=TEMPLATE_AIPASS)
stage_1_mock.assert_not_called() stage_1_mock.assert_not_called()
stage_4_mock.assert_called_once() stage_4_mock.assert_called_once()
@@ -719,3 +723,114 @@ class TestInitUpdateRegistrySync:
): ):
rc = _handle_init_update([str(tmp_path)]) rc = _handle_init_update([str(tmp_path)])
assert rc == 0 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}