c624eafe64
Disabling ssh-controlmaster mid --ssh session would tear down the ControlMaster (if we own it) and silently redirect read/write/edit/bash back to the local filesystem while the system prompt still claims we're on the remote. Now blocked with an explanatory dialog. Implementation: a DISABLE_GUARDS map keyed by bare extension name lets specific extensions register a refusal predicate. ssh-controlmaster's guard checks process.argv for --ssh and refuses if present. Easy to extend with similar foot-guns later.
205 lines
6.5 KiB
TypeScript
205 lines
6.5 KiB
TypeScript
/**
|
|
* 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");
|
|
|
|
/**
|
|
* 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": () => {
|
|
// If pi was launched with --ssh, disabling will (a) tear down the
|
|
// ControlMaster (if we own it) and (b) silently revert read/write/
|
|
// edit/bash to the LOCAL filesystem while the system prompt still
|
|
// claims we're on the remote. Refuse, point at the safe path.
|
|
const hasSshFlag = process.argv.some(
|
|
(a) => a === "--ssh" || a.startsWith("--ssh="),
|
|
);
|
|
if (!hasSshFlag) return null;
|
|
return [
|
|
"Refusing to disable ssh-controlmaster during an active --ssh session.",
|
|
"",
|
|
"Disabling now would:",
|
|
" \u2022 tear down the SSH ControlMaster (if this extension started it)",
|
|
" \u2022 silently redirect read/write/edit/bash back to the LOCAL",
|
|
" machine while the system prompt still says you're on the remote",
|
|
"",
|
|
"Exit pi and relaunch without --ssh instead.",
|
|
].join("\n");
|
|
},
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
// Run guard before prompting — only blocks disables, not re-enables.
|
|
if (entry.enabled) {
|
|
const guard = DISABLE_GUARDS[entry.name];
|
|
const reason = guard?.();
|
|
if (reason) {
|
|
await ctx.ui.confirm(`Cannot disable "${entry.name}"`, reason);
|
|
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");
|
|
}
|
|
},
|
|
});
|
|
}
|