feat(aipass): aipass init template selector — empty-project default + stage gating (TDPLAN-0010, FPLAN-0295)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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", ""))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user