index.ts
  1  /**
  2   * RP Native Tools Lock
  3   *
  4   * Disables Pi's native repo-file tools (read/write/edit/ls/find/grep) when RepoPrompt tools are available.
  5   *
  6   * Why:
  7   * - Some models will "reach" for the native tools because they appear first / are more familiar
  8   * - If RepoPrompt is available, we want to force repo-scoped work through rp (MCP) or rp_exec (CLI)
  9   *
 10   * Modes (user-facing):
 11   * - off     : no enforcement
 12   * - auto    : enforce via rp if available; else rp_exec if available; else off
 13   *            (auto mode only kicks in if the user has enabled `rp`/`rp_exec` in their active tools)
 14   *            Tip: use `/tools` to enable `rp` (and then toggle this lock with Alt+L or `/rp-tools-lock`)
 15   *
 16   * Advanced modes (set via config file):
 17   * - rp-mcp  : enforce when the `rp` tool exists
 18   * - rp-cli  : enforce when the `rp_exec` tool exists
 19   *
 20   * Hotkeys:
 21   * - Alt+L: toggle lock mode (off ↔ auto)
 22   *
 23   * Configuration precedence:
 24   * 1) Session branch override (via /rp-tools-lock)
 25   * 2) Global config file: ~/.pi/agent/extensions/rp-native-tools-lock/rp-native-tools-lock.json
 26   * 3) Default: auto
 27   */
 28  
 29  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
 30  import { homedir } from "node:os";
 31  import { dirname, join } from "node:path";
 32  
 33  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 34  import { Key, type KeyId } from "@mariozechner/pi-tui";
 35  
 36  type Mode = "off" | "auto" | "rp-mcp" | "rp-cli";
 37  
 38  interface LockState {
 39  	mode: Mode;
 40  }
 41  
 42  const CUSTOM_TYPE = "rp-native-tools-lock";
 43  const CONFIG_PATH = join(homedir(), ".pi", "agent", "extensions", "rp-native-tools-lock", "rp-native-tools-lock.json");
 44  
 45  const REQUIRED_TOOL_BY_MODE: Record<Exclude<Mode, "off" | "auto">, string> = {
 46  	"rp-mcp": "rp",
 47  	"rp-cli": "rp_exec",
 48  };
 49  
 50  const NATIVE_FILE_TOOLS = ["read", "write", "edit", "ls", "find", "grep"];
 51  
 52  const TOGGLE_MODE_HOTKEY: KeyId = Key.alt("l");
 53  
 54  // Keep the interactive UX simple: users only toggle off/auto.
 55  // Advanced modes remain supported via the config file.
 56  const MODE_CYCLE_ORDER: Mode[] = ["off", "auto"];
 57  
 58  function normalizeMode(raw: string | undefined): Mode | undefined {
 59  	const value = (raw ?? "").trim().toLowerCase();
 60  	if (!value) return undefined;
 61  
 62  	if (value === "off" || value === "disabled" || value === "none") return "off";
 63  	if (value === "auto" || value === "aut" || value === "automatic") return "auto";
 64  	if (value === "rp-mcp" || value === "mcp" || value === "rp") return "rp-mcp";
 65  	if (value === "rp-cli" || value === "cli" || value === "rp_exec" || value === "rp-exec") return "rp-cli";
 66  
 67  	return undefined;
 68  }
 69  
 70  function loadGlobalConfig(): LockState | undefined {
 71  	if (!existsSync(CONFIG_PATH)) return undefined;
 72  	try {
 73  		const content = readFileSync(CONFIG_PATH, "utf-8");
 74  		const parsed = JSON.parse(content) as Partial<LockState> | undefined;
 75  		const mode = normalizeMode(parsed?.mode);
 76  		return mode ? { mode } : undefined;
 77  	} catch {
 78  		return undefined;
 79  	}
 80  }
 81  
 82  function saveGlobalConfig(state: LockState): void {
 83  	try {
 84  		const configDir = dirname(CONFIG_PATH);
 85  		if (!existsSync(configDir)) {
 86  			mkdirSync(configDir, { recursive: true });
 87  		}
 88  		writeFileSync(CONFIG_PATH, JSON.stringify(state, null, 2));
 89  	} catch (err) {
 90  		console.error(`Failed to save ${CONFIG_PATH}: ${err}`);
 91  	}
 92  }
 93  
 94  function restoreFromBranch(ctx: ExtensionContext, fallback: LockState): LockState {
 95  	const branchEntries = ctx.sessionManager.getBranch();
 96  	let restored: LockState | undefined;
 97  
 98  	for (const entry of branchEntries) {
 99  		if (entry.type === "custom" && entry.customType === CUSTOM_TYPE) {
100  			const data = entry.data as Partial<LockState> | undefined;
101  			const mode = normalizeMode(data?.mode);
102  			if (mode) restored = { mode };
103  		}
104  	}
105  
106  	return restored ?? fallback;
107  }
108  
109  function setStatus(ctx: ExtensionContext, text: string | undefined): void {
110  	if (!ctx.hasUI) return;
111  	ctx.ui.setStatus("rp-tools-lock", text);
112  }
113  
114  type EffectiveMode = Exclude<Mode, "auto">;
115  
116  function computeEffectiveMode(
117  	allToolNames: Set<string>,
118  	activeToolNames: Set<string>,
119  	requestedMode: Mode,
120  ): { effectiveMode: EffectiveMode; requiredTool: string | undefined } {
121  	if (requestedMode === "off") return { effectiveMode: "off", requiredTool: undefined };
122  
123  	if (requestedMode === "auto") {
124  		// In auto mode, respect the user's tool configuration.
125  		// We only prefer a RepoPrompt entrypoint if the user has enabled it.
126  		if (activeToolNames.has("rp")) return { effectiveMode: "rp-mcp", requiredTool: "rp" };
127  		if (activeToolNames.has("rp_exec")) return { effectiveMode: "rp-cli", requiredTool: "rp_exec" };
128  		return { effectiveMode: "off", requiredTool: undefined };
129  	}
130  
131  	// Advanced/maintainer modes: enforce based on tool availability (even if currently disabled)
132  	return {
133  		effectiveMode: requestedMode,
134  		requiredTool: REQUIRED_TOOL_BY_MODE[requestedMode],
135  	};
136  }
137  
138  function buildStatusText(effectiveMode: EffectiveMode): string | undefined {
139  	if (effectiveMode === "rp-mcp" || effectiveMode === "rp-cli") {
140  		return "RP 🔒";
141  	}
142  	return undefined;
143  }
144  
145  function enforceMode(
146  	pi: ExtensionAPI,
147  	ctx: ExtensionContext,
148  	requestedMode: Mode,
149  ): { enforced: boolean; reason?: string; effectiveMode: EffectiveMode; requiredTool?: string } {
150  	const allToolNames = new Set(pi.getAllTools().map((t) => t.name));
151  	const activeToolNames = new Set(pi.getActiveTools());
152  	const { effectiveMode, requiredTool } = computeEffectiveMode(allToolNames, activeToolNames, requestedMode);
153  
154  	if (effectiveMode === "off") {
155  		setStatus(ctx, undefined);
156  		return {
157  			enforced: false,
158  			reason: requestedMode === "auto" ? "auto:no-rp-tools" : "mode=off",
159  			effectiveMode,
160  		};
161  	}
162  
163  	if (!requiredTool || !allToolNames.has(requiredTool)) {
164  		setStatus(ctx, undefined);
165  		return {
166  			enforced: false,
167  			reason: `missing:${requiredTool ?? "unknown"}`,
168  			effectiveMode,
169  		};
170  	}
171  
172  	const active = pi.getActiveTools();
173  	const blocked = new Set(NATIVE_FILE_TOOLS.filter((t) => allToolNames.has(t)));
174  	const next = active.filter((t) => !blocked.has(t));
175  
176  	// Ensure the required RepoPrompt tool stays available
177  	if (!next.includes(requiredTool)) next.push(requiredTool);
178  
179  
180  	// Only apply when changed
181  	const activeSet = new Set(active);
182  	const nextSet = new Set(next);
183  	const changed =
184  		active.length !== next.length ||
185  		active.some((t) => !nextSet.has(t)) ||
186  		next.some((t) => !activeSet.has(t));
187  
188  	if (changed) {
189  		pi.setActiveTools(next);
190  	}
191  
192  	setStatus(ctx, buildStatusText(effectiveMode));
193  	return { enforced: true, effectiveMode, requiredTool };
194  }
195  
196  export default function rpNativeToolsLock(pi: ExtensionAPI): void {
197  	let state: LockState = { mode: "auto" };
198  
199  	function resolveState(ctx: ExtensionContext): LockState {
200  		const globalConfig = loadGlobalConfig();
201  		const fallback = globalConfig ?? { mode: "auto" };
202  		return restoreFromBranch(ctx, fallback);
203  	}
204  
205  	function apply(ctx: ExtensionContext): void {
206  		state = resolveState(ctx);
207  		enforceMode(pi, ctx, state.mode);
208  	}
209  
210  	function persistState(nextState: LockState): void {
211  		// Persist globally + in-session branch
212  		saveGlobalConfig(nextState);
213  		pi.appendEntry<LockState>(CUSTOM_TYPE, nextState);
214  	}
215  
216  	function setMode(
217  		ctx: ExtensionContext,
218  		mode: Mode,
219  	): { enforced: boolean; reason?: string; effectiveMode: EffectiveMode; requiredTool?: string } {
220  		state = { mode };
221  		persistState(state);
222  		return enforceMode(pi, ctx, state.mode);
223  	}
224  
225  	function notifyEnforcement(
226  		ctx: ExtensionContext,
227  		requestedMode: Mode,
228  		enforced: { enforced: boolean; reason?: string; effectiveMode: EffectiveMode },
229  	): void {
230  		if (!ctx.hasUI) return;
231  
232  		if (requestedMode === "off") {
233  			ctx.ui.notify("rp-tools-lock: off", "info");
234  			return;
235  		}
236  
237  		if (enforced.enforced) {
238  			// Keep user-facing messaging simple. Advanced detail in config.
239  			if (requestedMode === "auto") {
240  				ctx.ui.notify("rp-tools-lock: auto (native file tools disabled)", "info");
241  				return;
242  			}
243  
244  			ctx.ui.notify(`rp-tools-lock: ${requestedMode} (native file tools disabled)`, "info");
245  			return;
246  		}
247  
248  		if (requestedMode === "auto" && enforced.effectiveMode === "off") {
249  			ctx.ui.notify("rp-tools-lock: auto (no rp/rp_exec tools available)", "info");
250  			return;
251  		}
252  
253  		ctx.ui.notify(`rp-tools-lock: ${requestedMode} (not enforced: ${enforced.reason ?? "unknown"})`, "warning");
254  	}
255  
256  	function getNextMode(currentMode: Mode): Mode {
257  		const index = MODE_CYCLE_ORDER.indexOf(currentMode);
258  		const safeIndex = index >= 0 ? index : 0;
259  		return MODE_CYCLE_ORDER[(safeIndex + 1) % MODE_CYCLE_ORDER.length];
260  	}
261  
262  	pi.registerCommand("rp-tools-lock", {
263  		description:
264  			"RepoPrompt-first tooling: off | auto (disables read/write/edit/ls/find/grep). Advanced modes are available via config file.",
265  		handler: async (args, ctx) => {
266  			const raw = args?.trim();
267  
268  			const ALLOWED_MODES: Mode[] = ["off", "auto"];
269  
270  			// No args → interactive selector (if UI available)
271  			if (!raw) {
272  				if (!ctx.hasUI) {
273  					console.error("Usage: /rp-tools-lock <off|auto>");
274  					return;
275  				}
276  
277  				const choice = await ctx.ui.select("RepoPrompt tool policy", ALLOWED_MODES);
278  				if (!choice) return;
279  				state = { mode: choice as Mode };
280  			} else {
281  				const mode = normalizeMode(raw);
282  				if (!mode || !ALLOWED_MODES.includes(mode)) {
283  					const message =
284  						`Usage: /rp-tools-lock <off|auto> (got: ${raw})\n` +
285  						"Advanced modes (rp-mcp/rp-cli) can be set via: " +
286  						"~/.pi/agent/extensions/rp-native-tools-lock/rp-native-tools-lock.json";
287  
288  					if (ctx.hasUI) {
289  						ctx.ui.notify(message, "error");
290  					} else {
291  						console.error(message);
292  					}
293  					return;
294  				}
295  
296  				state = { mode };
297  			}
298  
299  			persistState(state);
300  
301  			const enforced = enforceMode(pi, ctx, state.mode);
302  			notifyEnforcement(ctx, state.mode, enforced);
303  		},
304  	});
305  
306  	pi.registerShortcut(TOGGLE_MODE_HOTKEY, {
307  		description: "Toggle rp-tools-lock mode (off ↔ auto)",
308  		handler: async (ctx) => {
309  			const current = resolveState(ctx).mode;
310  			const next = getNextMode(current);
311  			const enforced = setMode(ctx, next);
312  			notifyEnforcement(ctx, next, enforced);
313  		},
314  	});
315  
316  	// Apply early and often (covers /tools toggles, session navigation, etc.)
317  	pi.on("session_start", async (_event, ctx) => apply(ctx));
318  	pi.on("session_tree", async (_event, ctx) => apply(ctx));
319  
320  	// Enforce right when the user submits a prompt (before the agent starts)
321  	pi.on("input", async (_event, ctx) => apply(ctx));
322  
323  	// Safety backstop: even if a tool somehow remains active, block the call with a clear reason
324  	pi.on("tool_call", async (event, ctx) => {
325  		state = resolveState(ctx);
326  
327  		const allToolNames = new Set(pi.getAllTools().map((t) => t.name));
328  		const activeToolNames = new Set(pi.getActiveTools());
329  		const { effectiveMode, requiredTool } = computeEffectiveMode(allToolNames, activeToolNames, state.mode);
330  		if (effectiveMode === "off" || !requiredTool) return;
331  		if (!allToolNames.has(requiredTool)) return;
332  
333  		if (!NATIVE_FILE_TOOLS.includes(event.toolName)) return;
334  
335  		const suffix = state.mode === "auto" ? ` → ${effectiveMode}` : "";
336  		return {
337  			block: true,
338  			reason:
339  				`rp-tools-lock (${state.mode}${suffix}): native tool "${event.toolName}" is disabled. ` +
340  				`Use RepoPrompt instead (tool: ${requiredTool}). ` +
341  				`You can disable this lock with /rp-tools-lock off.`,
342  		};
343  	});
344  }