1381a37115
Pi moved to its new home at earendil-works on 2026-05-07 (https://pi.dev/news/2026/5/7/pi-has-a-new-home). Affected packages: @mariozechner/pi-coding-agent -> @earendil-works/pi-coding-agent @mariozechner/pi-tui -> @earendil-works/pi-tui @mariozechner/pi-ai -> @earendil-works/pi-ai @mariozechner/pi-agent-core -> @earendil-works/pi-agent-core The old @mariozechner/* packages are deprecated on npm with the explicit message 'please use @earendil-works/pi-coding-agent instead going forward', and the version stream has moved on (old top-out 0.73.1; new currently 0.74.0). Anyone npm-installing the old names gets a deprecation warning + a stale binary. Sweep: - All 7 extension TypeScript files: import statements updated. - README, AGENTS, install.sh: textual references and the github.com/ mariozechner/pi-coding-agent URL pointed at github.com/earendil-works/ pi (the new monorepo root; coding-agent now lives at packages/coding-agent inside it). - Bun build of mcp-loader, ext-toggle, ssh-controlmaster verified clean. Brew install references (`brew install pi-coding-agent`) left as-is: the homebrew formula still works at 0.73.1 and a tap update is tracked upstream at earendil-works/pi#2755. Historical CHANGELOG entries are untouched.
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
/**
|
|
* Ext-Toggle Extension
|
|
*
|
|
* Provides `/ext` to list and toggle pi extensions installed under
|
|
* `~/.pi/agent/extensions/` (the global auto-discovery dir).
|
|
*
|
|
* UX:
|
|
* ↑/↓ navigate
|
|
* space stage a toggle (visual ●/○ flip, not yet applied)
|
|
* enter commit all staged changes and reload pi
|
|
* esc cancel, no changes applied
|
|
*
|
|
* 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. After commit, `ctx.reload()` is invoked so changes take
|
|
* effect without a restart.
|
|
*
|
|
* Subdirectory-style extensions (`name/index.ts`) are listed read-only —
|
|
* toggling a directory cleanly is more work than the renaming trick is
|
|
* worth. If you need to disable one, move the directory aside manually.
|
|
*
|
|
* DISABLE_GUARDS: per-extension predicates that refuse a stage attempt
|
|
* when toggling would silently break in-flight session state. The space
|
|
* key flip is reverted and a status line explains why.
|
|
*/
|
|
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import {
|
|
type ExtensionAPI,
|
|
getSettingsListTheme,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import {
|
|
Container,
|
|
Key,
|
|
matchesKey,
|
|
type SettingItem,
|
|
SettingsList,
|
|
} from "@earendil-works/pi-tui";
|
|
|
|
const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions");
|
|
|
|
// ── Disable guards ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Per-extension guards that refuse a disable when toggling would silently
|
|
* break in-flight session state. Returns a string explaining why the
|
|
* disable should be blocked, or null to allow it.
|
|
*
|
|
* Keyed by bare extension name (matches the file basename without `.ts`).
|
|
*/
|
|
const DISABLE_GUARDS: Record<string, () => string | null> = {
|
|
"ssh-controlmaster": () => {
|
|
const hasSshFlag = process.argv.some(
|
|
(a) => a === "--ssh" || a.startsWith("--ssh="),
|
|
);
|
|
if (!hasSshFlag) return null;
|
|
return "Active --ssh session — disabling would silently revert read/write/edit/bash to local. Exit pi and relaunch without --ssh.";
|
|
},
|
|
// ext-toggle owns the /ext slash command. Disabling it removes the
|
|
// only TUI surface for re-enabling itself: pi auto-discovers `*.ts`
|
|
// only, so once renamed to `.ts.off` the file is invisible to pi and
|
|
// the next /reload silently drops the command. The disabled state
|
|
// also persists — in containerized setups (opencode-devbox) the
|
|
// ~/.pi volume keeps the `.ts.off` rename across container recreate,
|
|
// so even nuking the container doesn't recover the surface. Recovery
|
|
// path is manual: shell into the container (or open a host shell),
|
|
// run `mv ~/.pi/agent/extensions/ext-toggle.ts.off \
|
|
// ~/.pi/agent/extensions/ext-toggle.ts`, then /reload.
|
|
"ext-toggle": () =>
|
|
"Disabling ext-toggle would remove the /ext command itself — only manual `mv ~/.pi/agent/extensions/ext-toggle.ts.off ~/.pi/agent/extensions/ext-toggle.ts` recovers it. Refusing.",
|
|
};
|
|
|
|
// ── Filesystem helpers ────────────────────────────────────────────────────────
|
|
|
|
type Entry = {
|
|
name: string;
|
|
fullPath: string;
|
|
enabled: boolean;
|
|
kind: "file" | "dir";
|
|
isSymlink: boolean;
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
if (stat.isDirectory()) {
|
|
const indexPath = path.join(full, "index.ts");
|
|
if (fs.existsSync(indexPath)) {
|
|
out.push({
|
|
name: ent.name,
|
|
fullPath: full,
|
|
enabled: true,
|
|
kind: "dir",
|
|
isSymlink,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (ent.name.endsWith(".ts")) {
|
|
out.push({
|
|
name: ent.name.slice(0, -3),
|
|
fullPath: full,
|
|
enabled: true,
|
|
kind: "file",
|
|
isSymlink,
|
|
});
|
|
} else if (ent.name.endsWith(".ts.off")) {
|
|
out.push({
|
|
name: ent.name.slice(0, -7),
|
|
fullPath: full,
|
|
enabled: false,
|
|
kind: "file",
|
|
isSymlink,
|
|
});
|
|
}
|
|
}
|
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/** Rename to match a target enabled state. Returns the new path. */
|
|
function applyState(entry: Entry, enabled: boolean): string {
|
|
const dir = path.dirname(entry.fullPath);
|
|
const target = path.join(dir, enabled ? `${entry.name}.ts` : `${entry.name}.ts.off`);
|
|
if (entry.fullPath === target) return target;
|
|
fs.renameSync(entry.fullPath, target);
|
|
return target;
|
|
}
|
|
|
|
// ── Settings list values ──────────────────────────────────────────────────────
|
|
|
|
const VAL_ON = "● enabled";
|
|
const VAL_OFF = "○ disabled";
|
|
const VAL_DIR = "[dir] (manage manually)";
|
|
|
|
function valueFor(enabled: boolean): string {
|
|
return enabled ? VAL_ON : VAL_OFF;
|
|
}
|
|
|
|
// ── Extension ────────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
// Staged state: name → enabled. Initialized from disk; mutated by space.
|
|
const staged = new Map<string, boolean>();
|
|
for (const e of entries) staged.set(e.name, e.enabled);
|
|
|
|
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
let statusText = "";
|
|
|
|
const items: SettingItem[] = entries.map((e) => {
|
|
if (e.kind === "dir") {
|
|
return {
|
|
id: e.name,
|
|
label: e.name,
|
|
currentValue: VAL_DIR,
|
|
description: "Subdirectory extension — toggle by moving the dir manually.",
|
|
};
|
|
}
|
|
const guard = DISABLE_GUARDS[e.name];
|
|
const guardNote = guard?.();
|
|
const desc = [
|
|
e.isSymlink ? "symlink" : "file",
|
|
guardNote ? `guarded: ${guardNote}` : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
return {
|
|
id: e.name,
|
|
label: e.name,
|
|
currentValue: valueFor(e.enabled),
|
|
values: [VAL_ON, VAL_OFF],
|
|
description: desc || undefined,
|
|
};
|
|
});
|
|
|
|
const container = new Container();
|
|
|
|
// Header
|
|
container.addChild({
|
|
render(_w: number) {
|
|
return [
|
|
theme.fg("accent", theme.bold("Extensions — ~/.pi/agent/extensions/")),
|
|
theme.fg(
|
|
"muted",
|
|
" space: toggle (stage) · enter: apply · esc: cancel",
|
|
),
|
|
"",
|
|
];
|
|
},
|
|
invalidate() {},
|
|
});
|
|
|
|
const settingsList = new SettingsList(
|
|
items,
|
|
Math.min(items.length + 2, 20),
|
|
getSettingsListTheme(),
|
|
(id, newValue) => {
|
|
// SettingsList just cycled this row. Update staged state, but
|
|
// first check the guard if this is a disable transition.
|
|
const entry = entries.find((e) => e.name === id);
|
|
if (!entry || entry.kind === "dir") {
|
|
// Should not happen — dir items have no `values` and won't cycle.
|
|
settingsList.updateValue(id, VAL_DIR);
|
|
return;
|
|
}
|
|
|
|
const stagingEnabled = newValue === VAL_ON;
|
|
|
|
// Guard check: disabling a guarded extension is refused.
|
|
if (!stagingEnabled) {
|
|
const reason = DISABLE_GUARDS[entry.name]?.();
|
|
if (reason) {
|
|
settingsList.updateValue(id, VAL_ON);
|
|
statusText = `⊘ ${entry.name}: ${reason}`;
|
|
tui.requestRender();
|
|
return;
|
|
}
|
|
}
|
|
|
|
staged.set(id, stagingEnabled);
|
|
|
|
// Reflect drift from disk in status line so the user knows what
|
|
// will be applied on Enter.
|
|
const drift: string[] = [];
|
|
for (const e of entries) {
|
|
if (e.kind !== "file") continue;
|
|
const s = staged.get(e.name);
|
|
if (s !== e.enabled) {
|
|
drift.push(`${e.name}→${s ? "on" : "off"}`);
|
|
}
|
|
}
|
|
statusText = drift.length
|
|
? `pending: ${drift.join(", ")}`
|
|
: "";
|
|
tui.requestRender();
|
|
},
|
|
() => {
|
|
// SettingsList's onCancel fires on escape.
|
|
done(undefined);
|
|
},
|
|
);
|
|
|
|
container.addChild(settingsList);
|
|
|
|
// Footer status line — shows pending changes or guard rejections.
|
|
container.addChild({
|
|
render(_w: number) {
|
|
if (!statusText) return [""];
|
|
const colored = statusText.startsWith("⊘")
|
|
? theme.fg("warning", statusText)
|
|
: theme.fg("muted", statusText);
|
|
return ["", colored];
|
|
},
|
|
invalidate() {},
|
|
});
|
|
|
|
return {
|
|
render(width: number) {
|
|
return container.render(width);
|
|
},
|
|
invalidate() {
|
|
container.invalidate();
|
|
},
|
|
handleInput(data: string) {
|
|
// Intercept Enter at the wrapper level so SettingsList doesn't
|
|
// consume it for cycling. Everything else (space, arrows, esc)
|
|
// passes through unchanged.
|
|
if (matchesKey(data, Key.enter)) {
|
|
// Commit staged → disk.
|
|
const errors: string[] = [];
|
|
let applied = 0;
|
|
for (const e of entries) {
|
|
if (e.kind !== "file") continue;
|
|
const s = staged.get(e.name);
|
|
if (s === undefined || s === e.enabled) continue;
|
|
try {
|
|
applyState(e, s);
|
|
applied++;
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
errors.push(`${e.name}: ${msg}`);
|
|
}
|
|
}
|
|
done(undefined);
|
|
if (errors.length) {
|
|
ctx.ui.notify(`ext-toggle: ${errors.join(" | ")}`, "error");
|
|
} else if (applied > 0) {
|
|
ctx.ui.notify(`ext-toggle: applied ${applied} change(s); reloading…`, "info");
|
|
ctx.reload().catch((err) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
ctx.ui.notify(`reload failed: ${msg}`, "error");
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
settingsList.handleInput?.(data);
|
|
tui.requestRender();
|
|
},
|
|
};
|
|
});
|
|
},
|
|
});
|
|
}
|