Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/guides/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`
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 <path>` 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:
Expand Down
7 changes: 7 additions & 0 deletions loopx/cli_commands/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
31 changes: 23 additions & 8 deletions loopx/slash_command_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
36 changes: 36 additions & 0 deletions tests/test_slash_command_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down