Add ext-toggle extension and /ext slash command

extensions/ext-toggle.ts:
  /ext lists ~/.pi/agent/extensions/ with active/disabled markers
  and toggles individual extensions by renaming between name.ts and
  name.ts.off (pi only auto-discovers *.ts). Calls ctx.reload() so the
  change takes effect without restarting pi.

  Subdirectory-style extensions (name/index.ts) are listed read-only
  in v1 — toggling a directory cleanly is more work than the rename
  trick is worth.

install.sh:
  --uninstall now matches both *.ts and *.ts.off symlinks pointing
  into this repo, so a disabled extension is still cleaned up.

README.md / AGENTS.md:
  Document ext-toggle alongside the others; AGENTS notes the API
  surface used (registerCommand, ui.select/confirm/notify, reload)
  and the rename-not-delete design decision.
This commit is contained in:
2026-05-07 20:26:41 +02:00
parent 9218fe512c
commit d2b2b3fb43
4 changed files with 233 additions and 7 deletions
+38
View File
@@ -21,6 +21,7 @@ extensions/
confirm-destructive.ts # Confirm before dangerous bash commands and session actions confirm-destructive.ts # Confirm before dangerous bash commands and session actions
git-checkpoint.ts # Git stash checkpoint per turn, restorable on /fork git-checkpoint.ts # Git stash checkpoint per turn, restorable on /fork
notify.ts # Native terminal notification when agent finishes 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/ install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/
package.json # pi package manifest — enables `pi install /path` as an alternative package.json # pi package manifest — enables `pi install /path` as an alternative
README.md # User-facing docs. 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. 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: No framework. Manual:
```bash ```bash
+19
View File
@@ -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 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/` 1. Drop a `.ts` file into `extensions/`
2. Re-run `./install.sh` — it picks up the new file and symlinks it 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 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. 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.
+164
View File
@@ -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");
}
},
});
}
+6 -1
View File
@@ -182,7 +182,11 @@ do_uninstall() {
echo echo
local removed=0 local removed=0
for dest in "${EXTENSIONS_DEST}"/*.ts; do # 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 [[ -e "$dest" || -L "$dest" ]] || continue
if link_into_repo "$dest"; then if link_into_repo "$dest"; then
rm "$dest" rm "$dest"
@@ -190,6 +194,7 @@ do_uninstall() {
(( removed++ )) || true (( removed++ )) || true
fi fi
done done
done
if [[ $removed -eq 0 ]]; then if [[ $removed -eq 0 ]]; then
ok "No symlinks pointing into this repo found — nothing removed." ok "No symlinks pointing into this repo found — nothing removed."