import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; const COMMAND_NAME = "project-model"; const PROJECT_SETTINGS_DISPLAY_PATH = `${CONFIG_DIR_NAME}/settings.json`; const PROJECT_SETTINGS_PATH = join(CONFIG_DIR_NAME, "settings.json"); const MODEL_KEYS = ["defaultProvider", "defaultModel", "defaultThinkingLevel"] as const; const SAVE_ALIASES = new Set(["save", "set", "pin"]); const CLEAR_ALIASES = new Set(["clear", "remove", "unset", "unpin"]); const SHOW_ALIASES = new Set(["show", "status"]); const HELP_ALIASES = new Set(["help", "-h", "--help"]); const COMPLETIONS = [ { value: "save", label: "save", description: `Save current provider/model/thinking to ${PROJECT_SETTINGS_DISPLAY_PATH}` }, { value: "clear", label: "clear", description: "Remove project-local provider/model/thinking overrides" }, { value: "show", label: "show", description: "Show current project-local model override" }, ] as const; type ProjectSettings = Record & { defaultProvider?: unknown; defaultModel?: unknown; defaultThinkingLevel?: unknown; }; type CwdContext = Pick; function isPlainObject(value: unknown): value is ProjectSettings { return typeof value === "object" && value !== null && !Array.isArray(value); } function settingsPath(ctx: CwdContext): string { return join(ctx.cwd, PROJECT_SETTINGS_PATH); } function formatModel(provider: string, modelId: string, thinkingLevel: string): string { return `${provider}/${modelId} (${thinkingLevel})`; } function usage(): string { return `Usage: /${COMMAND_NAME} [save|clear|show]\n` + ` save Save current provider/model/thinking to ${PROJECT_SETTINGS_DISPLAY_PATH}\n` + ` clear Remove defaultProvider/defaultModel/defaultThinkingLevel from ${PROJECT_SETTINGS_DISPLAY_PATH}\n` + ` show Show the current project-local model override`; } async function readProjectSettings(path: string): Promise { let raw: string; try { raw = await readFile(path, "utf8"); } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { return {}; } throw error; } if (raw.trim() === "") return {}; let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`${PROJECT_SETTINGS_DISPLAY_PATH} is not valid JSON: ${message}`); } if (!isPlainObject(parsed)) { throw new Error(`${PROJECT_SETTINGS_DISPLAY_PATH} must contain a JSON object.`); } return parsed; } async function writeProjectSettings(path: string, settings: ProjectSettings): Promise { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); } function hasProjectModelOverride(settings: ProjectSettings): boolean { return MODEL_KEYS.some((key) => Object.prototype.hasOwnProperty.call(settings, key)); } function getProjectModelOverride(settings: ProjectSettings) { if (!hasProjectModelOverride(settings)) return undefined; return { provider: typeof settings.defaultProvider === "string" ? settings.defaultProvider : undefined, modelId: typeof settings.defaultModel === "string" ? settings.defaultModel : undefined, thinkingLevel: typeof settings.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined, }; } function formatProjectModelOverride(settings: ProjectSettings): string | undefined { const override = getProjectModelOverride(settings); if (!override) return undefined; return `${override.provider ?? ""}/${override.modelId ?? ""} (${override.thinkingLevel ?? ""})`; } async function saveCurrentProjectModel(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise { const model = ctx.model; if (!model) { ctx.ui.notify("No model is currently selected.", "error"); return; } const path = settingsPath(ctx); const settings = await readProjectSettings(path); const thinkingLevel = pi.getThinkingLevel(); settings.defaultProvider = model.provider; settings.defaultModel = model.id; settings.defaultThinkingLevel = thinkingLevel; await writeProjectSettings(path, settings); ctx.ui.notify(`Saved project model: ${formatModel(model.provider, model.id, thinkingLevel)} -> ${PROJECT_SETTINGS_DISPLAY_PATH}`, "info"); } async function clearProjectModel(ctx: ExtensionCommandContext): Promise { const path = settingsPath(ctx); const settings = await readProjectSettings(path); if (!hasProjectModelOverride(settings)) { ctx.ui.notify(`No project model override found in ${PROJECT_SETTINGS_DISPLAY_PATH}.`, "info"); return; } for (const key of MODEL_KEYS) { delete settings[key]; } await writeProjectSettings(path, settings); ctx.ui.notify(`Removed project model override from ${PROJECT_SETTINGS_DISPLAY_PATH}.`, "info"); } async function showProjectModel(ctx: ExtensionCommandContext): Promise { const path = settingsPath(ctx); const settings = await readProjectSettings(path); const override = formatProjectModelOverride(settings); if (!override) { ctx.ui.notify(`No project model override set in ${PROJECT_SETTINGS_DISPLAY_PATH}.`, "info"); return; } ctx.ui.notify(`Project model override: ${override}`, "info"); } async function runInteractiveMenu(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise { if (!ctx.hasUI) { ctx.ui.notify(usage(), "info"); return; } const current = ctx.model ? `Save current ${formatModel(ctx.model.provider, ctx.model.id, pi.getThinkingLevel())}` : "Save current model (none selected)"; const choice = await ctx.ui.select("Project model override", [ current, "Show project override", "Clear project override", "Cancel", ]); if (!choice || choice === "Cancel") return; if (choice.startsWith("Save current")) return saveCurrentProjectModel(pi, ctx); if (choice.startsWith("Show")) return showProjectModel(ctx); if (choice.startsWith("Clear")) return clearProjectModel(ctx); } async function handleCommand(pi: ExtensionAPI, args: string, ctx: ExtensionCommandContext): Promise { const command = args.trim().split(/\s+/, 1)[0]?.toLowerCase() ?? ""; try { if (HELP_ALIASES.has(command)) { ctx.ui.notify(usage(), "info"); return; } if (!ctx.isProjectTrusted()) { ctx.ui.notify( `Project-local settings are disabled because this project is not trusted. Trust the project, restart Pi, and run /${COMMAND_NAME} again.`, "warning", ); return; } if (!command) return runInteractiveMenu(pi, ctx); if (SAVE_ALIASES.has(command)) return saveCurrentProjectModel(pi, ctx); if (CLEAR_ALIASES.has(command)) return clearProjectModel(ctx); if (SHOW_ALIASES.has(command)) return showProjectModel(ctx); ctx.ui.notify(`Unknown /${COMMAND_NAME} action: ${command}\n${usage()}`, "error"); } catch (error) { const message = error instanceof Error ? error.message : String(error); ctx.ui.notify(`/${COMMAND_NAME} failed: ${message}`, "error"); } } async function notifyProjectModelOverrideOnStartup(ctx: ExtensionContext): Promise { if (!ctx.hasUI || !ctx.isProjectTrusted()) return; try { const settings = await readProjectSettings(settingsPath(ctx)); const override = formatProjectModelOverride(settings); if (!override) return; ctx.ui.notify(`Project model override configured: ${override} from ${PROJECT_SETTINGS_DISPLAY_PATH}`, "info"); } catch (error) { const message = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Could not read project model override: ${message}`, "warning"); } } export default function projectModel(pi: ExtensionAPI) { pi.on("session_start", (event, ctx) => { if (event.reason !== "startup" && event.reason !== "reload") return; return notifyProjectModelOverrideOnStartup(ctx); }); pi.registerCommand(COMMAND_NAME, { description: "Save/remove project-local provider, model, and thinking defaults", getArgumentCompletions(argumentPrefix) { if (/\s/.test(argumentPrefix.trimStart())) return null; const prefix = argumentPrefix.trim().toLowerCase(); return COMPLETIONS.filter((item) => item.value.startsWith(prefix)).map((item) => ({ ...item })); }, handler: (args, ctx) => handleCommand(pi, args, ctx), }); }