diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index d9cabc9f27..f9fdcd3bdb 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -94,13 +94,19 @@ 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 the discoverable `index.ts` and runtime under + `/extensions/loopx/` for all projects. `` defaults to + `~/.pi/agent` and follows `PI_CODING_AGENT_DIR`. Run + `loopx slash-commands --inspect --surface pi` to read both scopes, including + stale or partial installs and a warning when both scopes would load. To + remove one scope, use `--uninstall --surface pi` with that scope; reinstall + with `--install` to upgrade managed files. 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..79a0f94b47 100644 --- a/loopx/cli_commands/slash_commands.py +++ b/loopx/cli_commands/slash_commands.py @@ -3,10 +3,8 @@ import argparse from collections.abc import Callable -from ..slash_command_install import ( - install_slash_commands, - render_slash_command_install_markdown, -) +from ..pi_goal_mode.installation import inspect_pi_installations, render_pi_installation_markdown +from ..slash_command_install import install_slash_commands, render_slash_command_install_markdown from ..slash_commands import build_slash_command_catalog, render_slash_command_catalog_markdown @@ -47,6 +45,11 @@ def register_slash_commands_command( action="store_true", help="Remove LoopX-managed command skill files for supported hosts while preserving user-owned files.", ) + install_group.add_argument( + "--inspect", + action="store_true", + help="Read project and user Pi extension installation state without writing files.", + ) parser.add_argument( "--surface", action="append", @@ -122,6 +125,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", @@ -137,6 +146,14 @@ def handle_slash_commands_command( ) -> int | None: if args.command != "slash-commands": return None + if args.inspect: + if args.surface != ["pi"]: + raise ValueError("--inspect requires exactly --surface pi") + if args.dry_run or args.with_goal_bridge: + raise ValueError("--inspect cannot be combined with --dry-run or --with-goal-bridge") + payload = inspect_pi_installations(pi_project=args.pi_project) + print_payload(payload, output_format(args), render_pi_installation_markdown) + return 0 if payload["ok"] else 1 if args.install or args.uninstall or args.dry_run: payload = install_slash_commands( execute=bool((args.install or args.uninstall) and not args.dry_run), @@ -152,6 +169,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/pi_goal_mode/README.md b/loopx/pi_goal_mode/README.md index fe386bbe6e..6e666880ca 100644 --- a/loopx/pi_goal_mode/README.md +++ b/loopx/pi_goal_mode/README.md @@ -45,6 +45,9 @@ into a LoopX-governed visible goal loop. ```bash loopx slash-commands --install --surface pi --pi-project . loopx slash-commands --uninstall --surface pi --pi-project . +loopx slash-commands --install --surface pi --pi-scope user +loopx slash-commands --inspect --surface pi --pi-project . +loopx slash-commands --uninstall --surface pi --pi-scope user ``` Installs two LoopX-managed files into the project (loaded after project @@ -62,6 +65,15 @@ installer at the target project so the command is correct even when run from another directory; `agent-onboard --agent-type pi --project ` emits the resolved project automatically. +The optional user scope installs the same adapter as +`/extensions/loopx/index.ts` with the runtime beside it. Pi +discovers `index.ts` inside extension subdirectories. `` defaults to +`~/.pi/agent` and follows `PI_CODING_AGENT_DIR`. Inspect reports both scopes as +absent, current, stale, partial, or user-owned, and warns when both entries +would load in the same project. Run Pi `/reload` or restart after install, +upgrade, or uninstall. Only the adapter is global: bindings remain under the +active project's `.loopx/pi/`. + ## State Bindings persist under `/.loopx/pi/` (gitignored), keyed by session. diff --git a/loopx/pi_goal_mode/installation.py b/loopx/pi_goal_mode/installation.py new file mode 100644 index 0000000000..c33f270419 --- /dev/null +++ b/loopx/pi_goal_mode/installation.py @@ -0,0 +1,113 @@ +"""Managed Pi extension locations and read-only installation state.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from ..slash_command_files import managed_marker as _managed_marker +from . import extension_source as pi_extension_source +from . import runtime_source as pi_runtime_source + + +def _pi_agent_dir(user_home: str | None = None) -> Path: + if user_home is not None: + return Path(user_home).expanduser().resolve() / ".pi" / "agent" + configured = os.environ.get("PI_CODING_AGENT_DIR") + if configured: + return Path(configured).expanduser().resolve() + return Path.home() / ".pi" / "agent" + + +def _pi_extension_root(project_root: Path, *, scope: str, agent_dir: Path) -> Path: + if scope == "user": + return agent_dir / "extensions" / "loopx" + return project_root / ".pi" / "extensions" + + +def _pi_extension_path(extension_root: Path, *, scope: str) -> Path: + # Pi discovers direct project files, but only index.ts/index.js (or a + # package manifest) inside a global extension subdirectory. + return extension_root / ("index.ts" if scope == "user" else "loopx-goal.ts") + + +def _pi_runtime_path(extension_root: Path) -> Path: + return extension_root / "pi-goal-loop-runtime.mjs" + + +def inspect_pi_installations( + *, pi_project: str | None = None, pi_user_home: str | None = None +) -> dict[str, Any]: + """Read both Pi discovery locations without mutating either installation.""" + project_root = Path(pi_project or ".").expanduser().resolve() + agent_dir = _pi_agent_dir(pi_user_home) + scopes: dict[str, dict[str, Any]] = {} + for scope in ("project", "user"): + root = _pi_extension_root(project_root, scope=scope, agent_dir=agent_dir) + extension = _pi_extension_path(root, scope=scope) + runtime = _pi_runtime_path(root) + files = (extension, runtime) + present = [path.exists() for path in files] + if not any(present): + status = "absent" + elif any(present) and not all(present): + status = "partial" + elif any( + _managed_marker(command="/loopx", surface=surface) + not in path.read_text(encoding="utf-8") + for path, surface in ((extension, "pi-extension"), (runtime, "pi-extension-runtime")) + ): + status = "user_owned" + elif ( + extension.read_text(encoding="utf-8") == pi_extension_source() + and runtime.read_text(encoding="utf-8") == pi_runtime_source() + ): + status = "current" + else: + status = "stale" + scopes[scope] = { + "status": status, + "extension_path": str(extension), + "runtime_path": str(runtime), + } + + project_entry = Path(scopes["project"]["extension_path"]).exists() + user_entry = Path(scopes["user"]["extension_path"]).exists() + duplicate_load = project_entry and user_entry + if duplicate_load: + location = "dual-scope" + elif any(row["status"] in {"stale", "partial", "user_owned"} for row in scopes.values()): + location = "stale" + elif scopes["user"]["status"] == "current": + location = "user-global" + elif scopes["project"]["status"] == "current": + location = "project-local" + else: + location = "absent" + return { + "ok": all(row["status"] in {"absent", "current"} for row in scopes.values()) + and not duplicate_load, + "schema_version": "loopx_pi_installation_readback_v0", + "location": location, + "scopes": scopes, + "duplicate_load_warning": ( + "Pi discovers both project and user LoopX entries; uninstall one scope to avoid duplicate command/tool registration." + if duplicate_load else None + ), + } + + +def render_pi_installation_markdown(payload: dict[str, Any]) -> str: + lines = ["# Pi installation readback", "", f"Location: `{payload['location']}`"] + for scope, row in payload["scopes"].items(): + lines.extend( + [ + f"- {scope}: `{row['status']}`", + f" - extension: `{row['extension_path']}`", + f" - runtime: `{row['runtime_path']}`", + ] + ) + if payload["duplicate_load_warning"]: + lines.append(f"\n{payload['duplicate_load_warning']}") + return "\n".join(lines) + "\n" diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index c36b124d43..589be9f807 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -16,6 +16,12 @@ from .opencode_goal_mode import plugin_source, runtime_source from .pi_goal_mode import extension_source as pi_extension_source from .pi_goal_mode import runtime_source as pi_runtime_source +from .pi_goal_mode.installation import ( + _pi_agent_dir, + _pi_extension_path, + _pi_extension_root, + _pi_runtime_path, +) from .slash_command_files import ( front_matter as _front_matter, install_skill_facade as _install_skill_facade, @@ -759,14 +765,6 @@ 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_runtime_path(project_root: Path) -> Path: - return project_root / ".pi" / "extensions" / "pi-goal-loop-runtime.mjs" - - def install_slash_commands( *, execute: bool, @@ -785,6 +783,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) @@ -796,7 +796,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_agent_dir = _pi_agent_dir(pi_user_home) + pi_extension_root = _pi_extension_root( + pi_project_root, scope=pi_scope, agent_dir=pi_agent_dir + ) installed: list[dict[str, Any]] = [] if with_goal_bridge and "opencode" not in effective_surfaces: @@ -1308,11 +1314,15 @@ 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, scope=pi_scope) + runtime_path = _pi_runtime_path(pi_extension_root) extension_content = pi_extension_source() runtime_content = pi_runtime_source() if uninstall: + # Uninstall stays per-file like every other surface: LoopX-managed + # files are removed and a user-owned file is reported as skipped. + # Aborting the whole scope instead would leave the managed adapter + # loaded and remove the only supported way to uninstall it. for mechanism, path in ( ("pi_goal_extension", extension_path), ("pi_goal_extension_runtime", runtime_path), @@ -1382,6 +1392,11 @@ def install_slash_commands( status = str(item["status"]) status_counts[status] = status_counts.get(status, 0) + 1 + pi_target_note = ( + "the user agent dir /extensions/loopx/ (scope=user)" + if pi_scope == "user" + else "the project's .pi/extensions/ (scope=project)" + ) return { "ok": not any(status.startswith("blocked_") for status in status_counts), "schema_version": SCHEMA_VERSION, @@ -1408,8 +1423,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, scope=pi_scope)) 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" @@ -1430,7 +1446,7 @@ def install_slash_commands( f"Kiro CLI discovers global skills from {_KIRO_SKILLS_ROOT_LABEL}//SKILL.md (default ~/.kiro/skills) and exposes each as a `/` slash command; the kiro-cli surface is opt-in and resolves KIRO_HOME so install and uninstall target the profile the running host reads. Kiro resolves .kiro/prompts and KIRO_HOME/prompts before skills, so a same-named user prompt shadows the managed skill.", "OpenCode discovers global skills from OPENCODE_CONFIG_DIR/skills in addition to the static command facade; a command is typed by the user, a skill can be reached by the model itself.", "The default all surface installs only OpenCode's static command facade; the executable goal bridge requires --with-goal-bridge.", - "The Pi surface is opt-in and installs the self-contained goal extension and its loop runtime into the project's .pi/extensions/; it is not part of the default all surface.", + f"The Pi surface is opt-in and installs the self-contained goal extension and its loop runtime into {pi_target_note}; it is not part of the default all surface.", "The OpenCode goal bridge uses Bun-managed config-directory dependencies and must replace any direct goal-plugin registration.", "OpenCode bridge uninstall preserves package.json dependencies because they may be shared by user-owned local plugins.", "Uninstall is fail-closed: it retires only files carrying the LoopX managed marker and leaves user-owned files in place.", diff --git a/tests/test_slash_command_install.py b/tests/test_slash_command_install.py index 9186c56daa..605b9ad9bc 100644 --- a/tests/test_slash_command_install.py +++ b/tests/test_slash_command_install.py @@ -3,7 +3,9 @@ import pytest +from loopx.entrypoint import main as loopx_main from loopx import slash_command_install +from loopx.pi_goal_mode.installation import inspect_pi_installations from loopx.slash_command_install import ( install_slash_commands, materialize_loopx_entry_skill, @@ -598,6 +600,202 @@ 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 / "index.ts") + assert payload["summary"]["pi_runtime_path"] == str(root / "pi-goal-loop-runtime.mjs") + assert any("scope=user" in note for note in payload["notes"]) + assert (root / "index.ts").is_file() + assert (root / "pi-goal-loop-runtime.mjs").is_file() + assert inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home))["location"] == "user-global" + + +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 / "index.ts").exists() + assert runtime.read_text(encoding="utf-8") == "user owned\n" + + +def test_pi_user_scope_preserves_user_entry_and_upgrades_managed_files(tmp_path: Path) -> None: + home = tmp_path / "home" + root = home / ".pi/agent/extensions/loopx" + root.mkdir(parents=True) + entry = root / "index.ts" + runtime = root / "pi-goal-loop-runtime.mjs" + entry.write_text("// user entry\n", encoding="utf-8") + + blocked = install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + assert blocked["ok"] is False + assert entry.read_text(encoding="utf-8") == "// user entry\n" + assert not runtime.exists() + + entry.write_text(slash_command_install.pi_extension_source() + "\n// old\n", encoding="utf-8") + stale = inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home)) + assert stale["scopes"]["user"]["status"] == "partial" + updated = install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + assert updated["ok"] is True + assert _row(updated, "pi_goal_extension")["status"] == "updated" + assert _row(updated, "pi_goal_extension_runtime")["status"] == "created" + assert inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home))["scopes"]["user"]["status"] == "current" + + +def test_pi_user_scope_follows_pi_agent_dir_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + agent_dir = tmp_path / "custom-agent-dir" + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(agent_dir)) + payload = install_slash_commands(execute=True, surfaces=["pi"], pi_scope="user") + + assert payload["summary"]["pi_extension_path"] == str(agent_dir / "extensions/loopx/index.ts") + assert (agent_dir / "extensions/loopx/index.ts").is_file() + assert inspect_pi_installations(pi_project=str(tmp_path))["scopes"]["user"]["status"] == "current" + + +def test_pi_inspect_cli_reads_selected_project_and_user_scope( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + agent_dir = tmp_path / "agent" + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(agent_dir)) + install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_project=str(tmp_path) + ) + + exit_code = loopx_main( + ["--format", "json", "slash-commands", "--inspect", "--surface", "pi", "--pi-project", str(tmp_path)] + ) + payload = json.loads(capsys.readouterr().out) + assert exit_code == 0 + assert payload["location"] == "user-global" + assert payload["scopes"]["project"]["status"] == "absent" + assert payload["scopes"]["user"]["extension_path"] == str(agent_dir / "extensions/loopx/index.ts") + + +def test_pi_readback_distinguishes_absent_stale_partial_and_dual_scope(tmp_path: Path) -> None: + home = tmp_path / "home" + + def readback() -> dict[str, object]: + return inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home)) + + assert readback()["location"] == "absent" + + install_slash_commands(execute=True, surfaces=["pi"], pi_project=str(tmp_path)) + assert readback()["location"] == "project-local" + assert readback()["scopes"]["project"]["status"] == "current" + + project_runtime = tmp_path / ".pi/extensions/pi-goal-loop-runtime.mjs" + project_runtime.write_text(project_runtime.read_text(encoding="utf-8") + "\n// old\n") + assert readback()["scopes"]["project"]["status"] == "stale" + project_runtime.unlink() + assert readback()["scopes"]["project"]["status"] == "partial" + + install_slash_commands(execute=True, surfaces=["pi"], pi_project=str(tmp_path)) + install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + dual = readback() + assert dual["location"] == "dual-scope" + assert dual["ok"] is False + assert "duplicate" in dual["duplicate_load_warning"] + + install_slash_commands( + execute=True, uninstall=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + assert readback()["location"] == "project-local" + + +def test_pi_user_uninstall_retires_managed_entry_and_keeps_user_runtime( + tmp_path: Path, +) -> None: + """A user-owned runtime must not make the managed adapter unremovable.""" + home = tmp_path / "home" + install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + root = home / ".pi/agent/extensions/loopx" + extension = root / "index.ts" + runtime = root / "pi-goal-loop-runtime.mjs" + runtime.write_text("// user replacement\n", encoding="utf-8") + + payload = install_slash_commands( + execute=True, uninstall=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + assert payload["ok"] is True + assert _row(payload, "pi_goal_extension")["status"] == "retired_managed_file" + assert _row(payload, "pi_goal_extension_runtime")["status"] == "skipped_user_file" + assert not extension.exists() + assert runtime.read_text(encoding="utf-8") == "// user replacement\n" + + +def test_pi_project_uninstall_survives_edited_runtime( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Regression: the default project scope must keep its per-file uninstall.""" + installed = loopx_main( + [ + "--format", + "json", + "slash-commands", + "--install", + "--surface", + "pi", + "--pi-project", + str(tmp_path), + ] + ) + capsys.readouterr() + assert installed == 0 + extension = tmp_path / ".pi/extensions/loopx-goal.ts" + runtime = tmp_path / ".pi/extensions/pi-goal-loop-runtime.mjs" + assert extension.is_file() and runtime.is_file() + runtime.write_text("// user replacement\n", encoding="utf-8") + + exit_code = loopx_main( + [ + "--format", + "json", + "slash-commands", + "--uninstall", + "--surface", + "pi", + "--pi-project", + str(tmp_path), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert exit_code == 0 + assert payload["ok"] is True + assert _row(payload, "pi_goal_extension")["status"] == "retired_managed_file" + assert _row(payload, "pi_goal_extension_runtime")["status"] == "skipped_user_file" + assert not extension.exists() + assert runtime.read_text(encoding="utf-8") == "// user replacement\n" + + def test_pi_install_does_not_touch_default_all_surfaces(tmp_path: Path) -> None: payload = install_slash_commands( execute=True,