mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 20:22:56 +00:00
Refactor Codex slash-command pickers so the Codex plugin owns the native command tree and returns portable presentation buttons for channels to render. Telegram now maps portable slash-command buttons to `tgcmd:` native callbacks while preserving approval callback shortening/bypass behavior, and the old Telegram-specific Codex callback menu path is gone. Verification: - `node scripts/run-vitest.mjs extensions/codex/src/command-plugins-management.test.ts extensions/codex/src/commands.test.ts extensions/telegram/src/button-types.test.ts` - `node scripts/run-vitest.mjs extensions/telegram/src/bot.test.ts extensions/telegram/src/button-types.test.ts extensions/telegram/src/bot-native-commands.test.ts extensions/telegram/src/shared.test.ts` - `node scripts/run-vitest.mjs run --config test/vitest/vitest.media-understanding.config.ts --reporter=verbose` - `pnpm check:test-types` - `pnpm tsgo:prod` - `pnpm lint --threads=8` - `git diff --check` - `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main` - CI `26714121462` Co-authored-by: Soham Patankar <102520430+yaanfpv@users.noreply.github.com>
267 lines
8.7 KiB
TypeScript
267 lines
8.7 KiB
TypeScript
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
|
|
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
|
|
import { formatCodexDisplayText } from "./command-formatters.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.";
|
|
|
|
type CodexPickerButton = { label: string; command: string };
|
|
|
|
function buildPickerPresentation(title: string, prompt: string, buttons: CodexPickerButton[]) {
|
|
return {
|
|
title,
|
|
blocks: [
|
|
{ type: "text", text: prompt },
|
|
{
|
|
type: "buttons",
|
|
buttons: buttons.map((button) => ({
|
|
label: button.label,
|
|
value: button.command,
|
|
})),
|
|
},
|
|
],
|
|
} satisfies MessagePresentation;
|
|
}
|
|
|
|
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: CodexPickerButton[] = [
|
|
{ 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: buildPickerPresentation(
|
|
"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: buildPickerPresentation(
|
|
"Codex sub-plugins",
|
|
"Pick another Codex sub-plugin action:",
|
|
[
|
|
{ label: "list", command: "/codex plugins list" },
|
|
{ label: "back", command: "/codex plugins menu" },
|
|
],
|
|
),
|
|
};
|
|
}
|
|
|
|
const buttons: CodexPickerButton[] = [
|
|
...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: buildPickerPresentation(
|
|
"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");
|
|
}
|