Files
openclaw/extensions/codex/src/command-plugins-management.ts
Peter Steinberger d641126c1d feat(plugin-sdk): add typed presentation command actions (#88721)
* feat(plugin-sdk): add typed presentation command actions

* test: use shared env helper in telegram bot tests

* test: expect typed approval actions

* test: expect typed sdk approval actions
2026-05-31 18:48:45 +01:00

249 lines
8.3 KiB
TypeScript

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<string, CodexPluginConfigEntry>;
}>;
mutate: (update: (block: CodexPluginsConfigBlock) => void) => Promise<void>;
};
export type CodexPluginConfigEntry = {
enabled?: boolean;
marketplaceName?: string;
pluginName?: string;
allow_destructive_actions?: boolean;
};
export type CodexPluginsConfigBlock = {
enabled?: boolean;
plugins?: Record<string, CodexPluginConfigEntry>;
};
// 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<PluginCommandResult> {
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} <name>` };
}
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 <name> enable a configured sub-plugin",
"- /codex plugins disable <name> disable a configured sub-plugin",
].join("\n");
}
export function formatPluginList(
plugins: Record<string, CodexPluginConfigEntry>,
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");
}