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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user