diff --git a/AGENTS.md b/AGENTS.md index 97f67ac..3a9a4e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ extensions/ confirm-destructive.ts # Confirm before dangerous bash commands and session actions git-checkpoint.ts # Git stash checkpoint per turn, restorable on /fork notify.ts # Native terminal notification when agent finishes + ext-toggle.ts # /ext slash command — list & toggle extensions at runtime install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/ package.json # pi package manifest — enables `pi install /path` as an alternative README.md # User-facing docs. @@ -159,6 +160,43 @@ Terminal detection order: `KITTY_WINDOW_ID` → OSC 99 (Kitty) → Notification text: `Pi — Done (Ns)` where N is the rounded elapsed seconds. +### `ext-toggle.ts` + +Registers `/ext` slash command. Lists files in `~/.pi/agent/extensions/`, +shows `●` (active) / `○` (disabled) plus dir/symlink hints, and lets the +user toggle individual extensions by renaming them between `name.ts` and +`name.ts.off`. Calls `ctx.reload()` after a toggle so the change takes +effect without restarting pi. + +**Key design decisions:** + +- **Rename, not delete.** Disabling a built-in produces a `name.ts.off` + symlink/file that's invisible to pi's `*.ts` discovery glob but trivially + reversible. No state stored elsewhere. +- **Symlink-friendly.** `fs.renameSync` renames the symlink itself; the repo + target is untouched. Toggling an extension installed by this repo is + reversible without re-running `install.sh`. +- **Subdir extensions are read-only in v1.** `name/index.ts` shapes show up in + the listing with a `[dir]` tag but cannot be toggled — the cleanest disable + for a directory would need a hidden-prefix or move-aside dance that adds + more failure modes than it's worth for now. +- **`install.sh --uninstall` matches both `*.ts` and `*.ts.off`.** Means a + disabled extension is still cleaned up on uninstall, regardless of which + state it was left in. +- **Listing is recomputed on every `/ext` invocation.** No cache, no event + subscription — cheap enough for the tens of files this directory will ever + contain. + +**API used:** + +- `pi.registerCommand(name, { description, handler })` — registers `/ext`. +- `ctx.ui.select(title, items)` — picker; returns selected string or `undefined`. +- `ctx.ui.confirm(title, message)` — yes/no dialog returning `boolean`. +- `ctx.ui.notify(message, level)` — transient toast. +- `ctx.reload()` — reloads extensions/skills/prompts/themes; same as `/reload`. + +No flags, no `agent_*` event handlers — fully passive until `/ext` is invoked. + No framework. Manual: ```bash diff --git a/README.md b/README.md index 548e036..7135d6e 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,28 @@ pi --notify-min-secs 15 # only notify for tasks over 15 seconds pi --notify-min-secs 0 # notify on every agent completion ``` +### `ext-toggle.ts` + +Registers `/ext` — a slash command that lists extensions in `~/.pi/agent/extensions/` and toggles individual ones on/off without leaving the TUI. + +**How it works:** pi auto-discovers `*.ts` only. Toggling renames a file (or symlink) between `name.ts` and `name.ts.off`, so a disabled extension is invisible to the loader. After a toggle, the extension calls `ctx.reload()` so the change takes effect immediately — no restart needed. + +**Usage:** + +``` +/ext # opens a picker; ● = active, ○ = disabled +``` + +**Notes:** +- Subdirectory-style extensions (`name/index.ts`) are listed read-only — v1 doesn't toggle them. Move the directory aside manually if needed. +- `install.sh --uninstall` cleans up both `.ts` and `.ts.off` symlinks pointing into this repo, so a disabled extension won't be left behind. + +## Adding a new extension + 1. Drop a `.ts` file into `extensions/` 2. Re-run `./install.sh` — it picks up the new file and symlinks it 3. In a running pi session, `/reload` is enough; no restart needed +4. (or, with `ext-toggle` installed: `/ext` to disable noisy ones at runtime) Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/jiti) — no compilation step. See the [pi extensions docs](https://github.com/mariozechner/pi-coding-agent/blob/main/docs/extensions.md) and the [built-in examples](https://github.com/mariozechner/pi-coding-agent/tree/main/examples/extensions) for the API surface. diff --git a/extensions/ext-toggle.ts b/extensions/ext-toggle.ts new file mode 100644 index 0000000..27322b7 --- /dev/null +++ b/extensions/ext-toggle.ts @@ -0,0 +1,164 @@ +/** + * Ext-Toggle Extension + * + * Provides `/ext` to list and toggle pi extensions installed under + * `~/.pi/agent/extensions/` (the global auto-discovery dir). + * + * Toggling works by renaming the file (or symlink) between `name.ts` and + * `name.ts.off`. Pi discovers `*.ts` only, so a `.ts.off` file is invisible + * at load time. `ctx.reload()` is invoked after a toggle so the change takes + * effect without a restart. + * + * Subdirectory-style extensions (`name/index.ts`) are listed as read-only + * in v1 — toggling a directory cleanly is more work than the renaming trick + * is worth. If you need to disable one, move the directory aside manually. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions"); + +type Entry = { + name: string; // bare name without extension (e.g. "notify") + fullPath: string; // absolute path on disk + enabled: boolean; // .ts (true) or .ts.off (false) + kind: "file" | "dir"; // flat .ts file or subdir/index.ts + isSymlink: boolean; + linkTarget?: string; // resolved target if symlink +}; + +function listEntries(): Entry[] { + if (!fs.existsSync(EXT_DIR)) return []; + const out: Entry[] = []; + for (const ent of fs.readdirSync(EXT_DIR, { withFileTypes: true })) { + const full = path.join(EXT_DIR, ent.name); + const isSymlink = ent.isSymbolicLink(); + let stat: fs.Stats | undefined; + try { + stat = fs.statSync(full); + } catch { + continue; + } + let linkTarget: string | undefined; + if (isSymlink) { + try { + linkTarget = fs.readlinkSync(full); + } catch { + // ignore + } + } + + if (stat.isDirectory()) { + // Subdir extension: pi expects index.ts inside. + const indexPath = path.join(full, "index.ts"); + if (fs.existsSync(indexPath)) { + out.push({ + name: ent.name, + fullPath: full, + enabled: true, + kind: "dir", + isSymlink, + linkTarget, + }); + } + continue; + } + + if (ent.name.endsWith(".ts")) { + out.push({ + name: ent.name.slice(0, -3), + fullPath: full, + enabled: true, + kind: "file", + isSymlink, + linkTarget, + }); + } else if (ent.name.endsWith(".ts.off")) { + out.push({ + name: ent.name.slice(0, -7), + fullPath: full, + enabled: false, + kind: "file", + isSymlink, + linkTarget, + }); + } + } + return out.sort((a, b) => a.name.localeCompare(b.name)); +} + +function formatRow(e: Entry): string { + const dot = e.enabled ? "●" : "○"; + const kind = e.kind === "dir" ? " [dir]" : ""; + const link = e.isSymlink ? " ↳ symlink" : ""; + return `${dot} ${e.name}${kind}${link}`; +} + +function toggleFile(e: Entry): { newPath: string; action: "enabled" | "disabled" } { + if (e.kind !== "file") { + throw new Error(`Cannot toggle directory-style extension "${e.name}" — move it aside manually.`); + } + const dir = path.dirname(e.fullPath); + if (e.enabled) { + const target = path.join(dir, `${e.name}.ts.off`); + fs.renameSync(e.fullPath, target); + return { newPath: target, action: "disabled" }; + } else { + const target = path.join(dir, `${e.name}.ts`); + fs.renameSync(e.fullPath, target); + return { newPath: target, action: "enabled" }; + } +} + +export default function extToggleExtension(pi: ExtensionAPI) { + pi.registerCommand("ext", { + description: "List and toggle pi extensions in ~/.pi/agent/extensions/", + handler: async (_args, ctx) => { + const entries = listEntries(); + if (entries.length === 0) { + ctx.ui.notify(`No extensions found in ${EXT_DIR}`, "info"); + return; + } + + const labels = entries.map(formatRow); + const selected = await ctx.ui.select( + `Extensions (${entries.filter((e) => e.enabled).length}/${entries.length} active)`, + labels, + ); + if (!selected) return; + + const idx = labels.indexOf(selected); + if (idx < 0) return; + const entry = entries[idx]; + + if (entry.kind === "dir") { + ctx.ui.notify( + `"${entry.name}" is a directory-style extension. Move it aside manually if you need to disable it.`, + "info", + ); + return; + } + + const verb = entry.enabled ? "Disable" : "Enable"; + const ok = await ctx.ui.confirm( + `${verb} "${entry.name}"?`, + `${entry.fullPath}\n\nWill be renamed to ${ + entry.enabled ? entry.name + ".ts.off" : entry.name + ".ts" + } and pi will reload.`, + ); + if (!ok) return; + + try { + const { action } = toggleFile(entry); + ctx.ui.notify(`${entry.name}: ${action}. Reloading…`, "info"); + await ctx.reload(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Toggle failed: ${msg}`, "error"); + } + }, + }); +} diff --git a/install.sh b/install.sh index 24e9672..5683542 100755 --- a/install.sh +++ b/install.sh @@ -182,13 +182,18 @@ do_uninstall() { echo local removed=0 - for dest in "${EXTENSIONS_DEST}"/*.ts; do - [[ -e "$dest" || -L "$dest" ]] || continue - if link_into_repo "$dest"; then - rm "$dest" - ok "Removed $(basename "$dest")" - (( removed++ )) || true - fi + # Match both active (.ts) and disabled (.ts.off) symlinks — the ext-toggle + # extension can rename a link to .ts.off to disable it, and uninstall + # should still clean those up. + for pattern in "*.ts" "*.ts.off"; do + for dest in "${EXTENSIONS_DEST}"/$pattern; do + [[ -e "$dest" || -L "$dest" ]] || continue + if link_into_repo "$dest"; then + rm "$dest" + ok "Removed $(basename "$dest")" + (( removed++ )) || true + fi + done done if [[ $removed -eq 0 ]]; then