import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; import { formatCodexDisplayText } from "./command-formatters.js"; import { buildCodexCommandPickerPresentation, type CodexCommandPickerButton, } from "./command-presentation.js"; /** * Lightweight read/write surface over the Openclaw config file. Plugged in by * the command registration site so this module stays decoupled from the * concrete `mutateConfigFile` import in tests. */ export type CodexPluginsManagementIO = { readConfig: () => Promise<{ enabled?: boolean; plugins?: Record; }>; mutate: (update: (block: CodexPluginsConfigBlock) => void) => Promise; }; export type CodexPluginConfigEntry = { enabled?: boolean; marketplaceName?: string; pluginName?: string; allow_destructive_actions?: boolean; }; export type CodexPluginsConfigBlock = { enabled?: boolean; plugins?: Record; }; // Plugin lifecycle changes (enable/disable) write to openclaw.json // synchronously. The Codex app-server picks up the new policy when the next // thread starts; in-flight conversations keep the old policy until /new or // /reset. A full gateway restart is NOT needed. const POLICY_REFRESH_HINT = "New Codex conversations pick this up automatically. Use /new or /reset to refresh the current one."; export async function handleCodexPluginsSubcommand( ctx: PluginCommandContext, rest: string[], io: CodexPluginsManagementIO, ): Promise { const [verb = "list", ...args] = rest; const normalized = verb.toLowerCase(); if (normalized === "menu") { if (args.length > 0) { return { text: "Usage: /codex plugins menu" }; } return buildPluginsMenuReply(); } if (normalized === "help") { if (args.length > 0) { return { text: "Usage: /codex plugins help" }; } return { text: buildPluginsHelp() }; } if (normalized === "list") { if (args.length > 0) { return { text: "Usage: /codex plugins list" }; } const current = await io.readConfig(); return { text: formatPluginList(current.plugins ?? {}, { globalEnabled: current.enabled === true }), }; } const target = args[0]; if (normalized === "enable" || normalized === "disable") { if (args.length === 0) { const current = await io.readConfig(); return buildPluginNamePickerReply(normalized, current); } if (!target || args.length > 1) { return { text: `Usage: /codex plugins ${normalized} ` }; } if (!canMutateCodexPlugins(ctx)) { return { text: `Only an owner or operator.admin gateway client can run /codex plugins ${normalized}.`, }; } const wantEnabled = normalized === "enable"; const current = (await io.readConfig()).plugins ?? {}; if (!current[target]) { return { text: `Codex sub-plugin '${formatCodexDisplayText(target)}' is not configured. Run '/codex plugins list' to see configured plugins.`, }; } await io.mutate((block) => { if (wantEnabled) { block.enabled = true; } block.plugins ??= {}; block.plugins[target] = { ...block.plugins[target], enabled: wantEnabled }; }); return { text: `${formatCodexDisplayText(target)}: ${wantEnabled ? "enabled" : "disabled"} in openclaw.json. ${POLICY_REFRESH_HINT}`, }; } return { text: `Unknown /codex plugins subcommand: ${formatCodexDisplayText(verb)}\n\n${buildPluginsHelp()}`, }; } function buildPluginsMenuReply(): PluginCommandResult { const buttons: CodexCommandPickerButton[] = [ { label: "list", command: "/codex plugins list" }, { label: "enable", command: "/codex plugins enable" }, { label: "disable", command: "/codex plugins disable" }, { label: "help", command: "/codex plugins help" }, { label: "back", command: "/codex" }, ]; const text = [ "Codex sub-plugins. Pick a sub-action or type:", "", " 1. /codex plugins list", " 2. /codex plugins enable", " 3. /codex plugins disable", " 4. /codex plugins help", "", "Type '/codex' to go back to the main menu.", ].join("\n"); return { text, presentation: buildCodexCommandPickerPresentation( "Codex sub-plugins", "Pick a Codex sub-plugin action:", buttons, ), }; } function buildPluginNamePickerReply( verb: "enable" | "disable", current: CodexPluginsConfigBlock, ): PluginCommandResult { const globalEnabled = current.enabled === true; const entries = Object.entries(current.plugins ?? {}).toSorted(([left], [right]) => left.localeCompare(right), ); const eligible = entries.filter(([, entry]) => { const effectivelyEnabled = globalEnabled && entry.enabled !== false; return verb === "disable" ? effectivelyEnabled : !effectivelyEnabled; }); if (eligible.length === 0) { const action = verb === "enable" ? "disabled" : "enabled"; return { text: [ `No configured ${action} Codex sub-plugins found.`, "", "Type '/codex plugins list' to inspect configured sub-plugins.", "Type '/codex plugins menu' to go back to the plugins menu.", ].join("\n"), presentation: buildCodexCommandPickerPresentation( "Codex sub-plugins", "Pick another Codex sub-plugin action:", [ { label: "list", command: "/codex plugins list" }, { label: "back", command: "/codex plugins menu" }, ], ), }; } const buttons: CodexCommandPickerButton[] = [ ...eligible.map(([key]) => ({ label: formatCodexDisplayText(key), command: `/codex plugins ${verb} ${key}`, })), { label: "back", command: "/codex plugins menu" }, ]; const text = [ `Codex sub-plugins to ${verb}. Pick one or type:`, "", ...eligible.map(([key], index) => ` ${index + 1}. /codex plugins ${verb} ${key}`), "", ...(verb === "enable" && !globalEnabled ? ["Global codexPlugins.enabled is off; enabling one configured sub-plugin turns it on.", ""] : []), "Type '/codex plugins menu' to go back to the plugins menu.", ].join("\n"); return { text, presentation: buildCodexCommandPickerPresentation( "Codex sub-plugins", `Pick a Codex sub-plugin to ${verb}:`, buttons, ), }; } function canMutateCodexPlugins(ctx: PluginCommandContext): boolean { if (ctx.senderIsOwner === true) { return true; } return ctx.gatewayClientScopes?.includes("operator.admin") === true; } export function buildPluginsHelp(): string { return [ "Codex sub-plugin management (writes only to ~/.openclaw/openclaw.json, never to ~/.codex/config.toml):", "- /codex plugins (alias for list)", "- /codex plugins list show all configured Codex sub-plugins", "- /codex plugins enable enable a configured sub-plugin", "- /codex plugins disable disable a configured sub-plugin", ].join("\n"); } export function formatPluginList( plugins: Record, options: { globalEnabled?: boolean } = {}, ): string { const globalEnabled = options.globalEnabled === true; const keys = Object.keys(plugins).toSorted(); if (keys.length === 0) { return "No Codex sub-plugins configured under plugins.entries.codex.config.codexPlugins.plugins"; } const rows = keys.map((key) => { const entry = plugins[key] ?? {}; const state = globalEnabled && entry.enabled !== false ? "ON " : "OFF"; const displayKey = formatCodexDisplayText(key); const pluginName = formatCodexDisplayText(entry.pluginName ?? key); const marketplace = formatCodexDisplayText(entry.marketplaceName ?? "?"); return { displayKey, state, pluginName, marketplace }; }); const keyW = Math.max(...rows.map((r) => r.displayKey.length)); const pluginW = Math.max(...rows.map((r) => r.pluginName.length)); return [ "Codex sub-plugins in Openclaw config (~/.openclaw/openclaw.json):", "", ...rows.map( (r) => ` ${r.state} ${r.displayKey.padEnd(keyW)} ${r.pluginName.padEnd(pluginW)} [${r.marketplace}]`, ), "", ...(globalEnabled ? [] : ["Global codexPlugins.enabled is off; configured sub-plugins are inactive.", ""]), "New Codex conversations pick up policy changes automatically; /new or /reset to refresh the current one.", ].join("\n"); }