diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 7866b80179..9ced88565c 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -94,13 +94,13 @@ can discover user-installed skills: which drives the session over the OpenCode 2 HTTP API and owns the loop timers, so long runs survive TUI close. OpenCode 1 plugins do not run under OpenCode 2; see `loopx/opencode2_goal_mode/README.md`. -- Pi: the self-contained goal extension under `.pi/extensions/loopx-goal.ts` - (with its loop core in `.pi/extensions/pi-goal-loop-runtime.mjs`) exposes - `/loopx` after restart and runs the quota-gated goal loop through - `loopx_goal_activate`. It is installed explicitly with - `loopx slash-commands --install --surface pi` (pass `--pi-project ` - to target another project from a different directory); private binding state - stays under each project's `.loopx/pi/` (already gitignored via `.loopx/`). +- Pi: the self-contained goal extension exposes `/loopx` after `/reload` or a + restart and runs the quota-gated goal loop through `loopx_goal_activate`. + `loopx slash-commands --install --surface pi` installs it for the current + project; add `--pi-project ` for another project, or `--pi-scope user` + to install it under `~/.pi/agent/extensions/loopx/` for all projects. Only + extension code is global: private bindings remain under each project's + `.loopx/pi/` (already gitignored via `.loopx/`). The command family is the same across surfaces, even when the host-specific entry point is different: diff --git a/loopx/cli_commands/slash_commands.py b/loopx/cli_commands/slash_commands.py index 5a9604a9e9..28893d3891 100644 --- a/loopx/cli_commands/slash_commands.py +++ b/loopx/cli_commands/slash_commands.py @@ -122,6 +122,12 @@ def register_slash_commands_command( default=".", help="Project directory for the Pi goal extension install. Defaults to the current directory.", ) + parser.add_argument( + "--pi-scope", + choices=("project", "user"), + default="project", + help="Install Pi extension code for one project or the current user. Defaults to project.", + ) parser.add_argument( "--dry-run", action="store_true", @@ -152,6 +158,7 @@ def handle_slash_commands_command( cursor_home=args.cursor_home, zcode_home=getattr(args, "zcode_home", None), pi_project=args.pi_project, + pi_scope=args.pi_scope, ) print_payload(payload, output_format(args), render_slash_command_install_markdown) return 0 if payload.get("ok") is True else 1 diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index d60b5da97e..acddc0b489 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -746,12 +746,18 @@ def _merge_cursor_mcp(cursor_root: Path, *, uninstall: bool, execute: bool) -> s return "written" -def _pi_extension_path(project_root: Path) -> Path: - return project_root / ".pi" / "extensions" / "loopx-goal.ts" +def _pi_extension_root(project_root: Path, *, scope: str, user_home: Path) -> Path: + if scope == "user": + return user_home / ".pi" / "agent" / "extensions" / "loopx" + return project_root / ".pi" / "extensions" -def _pi_runtime_path(project_root: Path) -> Path: - return project_root / ".pi" / "extensions" / "pi-goal-loop-runtime.mjs" +def _pi_extension_path(extension_root: Path) -> Path: + return extension_root / "loopx-goal.ts" + + +def _pi_runtime_path(extension_root: Path) -> Path: + return extension_root / "pi-goal-loop-runtime.mjs" def install_slash_commands( @@ -772,6 +778,8 @@ def install_slash_commands( agy_home: str | None = None, kiro_home: str | None = None, pi_project: str | None = None, + pi_scope: str = "project", + pi_user_home: str | None = None, ) -> dict[str, Any]: specs = _command_prompt_specs(cli_bin=cli_bin, include_legacy_aliases=include_legacy_aliases) effective_surfaces = _normalize_surfaces(surfaces) @@ -783,7 +791,13 @@ def install_slash_commands( zcode_root = _zcode_home(zcode_home or zcode_agents_home) agy_root = _agy_home(agy_home) kiro_root = _kiro_home(kiro_home) + if pi_scope not in {"project", "user"}: + raise ValueError("pi_scope must be 'project' or 'user'") pi_project_root = Path(pi_project or ".").expanduser().resolve() + pi_home = Path(pi_user_home).expanduser().resolve() if pi_user_home else Path.home() + pi_extension_root = _pi_extension_root( + pi_project_root, scope=pi_scope, user_home=pi_home + ) installed: list[dict[str, Any]] = [] if with_goal_bridge and "opencode" not in effective_surfaces: @@ -1295,8 +1309,8 @@ def install_slash_commands( ) if "pi" in effective_surfaces: - extension_path = _pi_extension_path(pi_project_root) - runtime_path = _pi_runtime_path(pi_project_root) + extension_path = _pi_extension_path(pi_extension_root) + runtime_path = _pi_runtime_path(pi_extension_root) extension_content = pi_extension_source() runtime_content = pi_runtime_source() if uninstall: @@ -1395,8 +1409,9 @@ def install_slash_commands( "opencode_command_dir": str(opencode_root / "commands") if "opencode" in effective_surfaces else None, "opencode_plugin_path": str(opencode_root / "plugins" / "loopx-goal.js") if "opencode" in effective_surfaces and with_goal_bridge else None, "opencode_package_path": str(opencode_root / "package.json") if "opencode" in effective_surfaces and with_goal_bridge else None, - "pi_extension_path": str(_pi_extension_path(pi_project_root)) if "pi" in effective_surfaces else None, - "pi_runtime_path": str(_pi_runtime_path(pi_project_root)) if "pi" in effective_surfaces else None, + "pi_scope": pi_scope if "pi" in effective_surfaces else None, + "pi_extension_path": str(_pi_extension_path(pi_extension_root)) if "pi" in effective_surfaces else None, + "pi_runtime_path": str(_pi_runtime_path(pi_extension_root)) if "pi" in effective_surfaces else None, "status_counts": status_counts, "skip_policy": ( "Uninstall removes only LoopX-managed files; user files without a LoopX managed marker are preserved" diff --git a/tests/test_slash_command_install.py b/tests/test_slash_command_install.py index 0cff76939e..afd9e7f892 100644 --- a/tests/test_slash_command_install.py +++ b/tests/test_slash_command_install.py @@ -572,6 +572,42 @@ def test_pi_install_writes_self_contained_extension_into_project( assert not (tmp_path / ".pi" / "extensions" / "package.json").exists() +def test_pi_user_scope_installs_atomic_extension_unit(tmp_path: Path) -> None: + home = tmp_path / "home" + payload = install_slash_commands( + execute=True, + surfaces=["pi"], + pi_scope="user", + pi_user_home=str(home), + ) + + root = home / ".pi" / "agent" / "extensions" / "loopx" + assert payload["summary"]["pi_scope"] == "user" + assert payload["summary"]["pi_extension_path"] == str(root / "loopx-goal.ts") + assert payload["summary"]["pi_runtime_path"] == str(root / "pi-goal-loop-runtime.mjs") + assert (root / "loopx-goal.ts").is_file() + assert (root / "pi-goal-loop-runtime.mjs").is_file() + + +def test_pi_user_scope_preflight_blocks_both_files(tmp_path: Path) -> None: + home = tmp_path / "home" + root = home / ".pi" / "agent" / "extensions" / "loopx" + root.mkdir(parents=True) + runtime = root / "pi-goal-loop-runtime.mjs" + runtime.write_text("user owned\n", encoding="utf-8") + + payload = install_slash_commands( + execute=True, + surfaces=["pi"], + pi_scope="user", + pi_user_home=str(home), + ) + + assert payload["ok"] is False + assert not (root / "loopx-goal.ts").exists() + assert runtime.read_text(encoding="utf-8") == "user owned\n" + + def test_pi_install_does_not_touch_default_all_surfaces(tmp_path: Path) -> None: payload = install_slash_commands( execute=True,