fix(cli): add 'approve' command for exec approval requests (#3258)
Add a new CLI command `moltmate approve <id> <decision>` that allows users to resolve exec approval requests directly from the command line. This mirrors the functionality of the `/approve` chat command, fixing the issue where the agent would incorrectly suggest running `clawdbot approve` which didn't exist. The command: - Takes an approval ID and decision (allow-once|allow-always|deny) - Supports common aliases (once, always, reject, block, etc.) - Calls the same gateway `exec.approval.resolve` endpoint as /approve - Provides user-friendly success/error messages Closes #3258
This commit is contained in:
parent
9688454a30
commit
6b24dd0383
160
src/cli/approve-cli.test.ts
Normal file
160
src/cli/approve-cli.test.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { Command } from "commander";
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const callGateway = vi.fn(async () => ({}));
|
||||||
|
|
||||||
|
const runtimeLogs: string[] = [];
|
||||||
|
const runtimeErrors: string[] = [];
|
||||||
|
const defaultRuntime = {
|
||||||
|
log: (msg: string) => runtimeLogs.push(msg),
|
||||||
|
error: (msg: string) => runtimeErrors.push(msg),
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`__exit__:${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../gateway/call.js", () => ({
|
||||||
|
callGateway: (opts: unknown) => callGateway(opts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../runtime.js", () => ({
|
||||||
|
defaultRuntime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("approve CLI", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runtimeLogs.length = 0;
|
||||||
|
runtimeErrors.length = 0;
|
||||||
|
callGateway.mockClear();
|
||||||
|
callGateway.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves approval with allow-once", async () => {
|
||||||
|
const { registerApproveCli } = await import("./approve-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerApproveCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["approve", "req-123", "allow-once"], { from: "user" });
|
||||||
|
|
||||||
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "exec.approval.resolve",
|
||||||
|
params: { id: "req-123", decision: "allow-once" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(runtimeLogs).toContain("✅ Exec approval allow-once submitted for req-123.");
|
||||||
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves approval with allow-always", async () => {
|
||||||
|
const { registerApproveCli } = await import("./approve-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerApproveCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["approve", "req-456", "allow-always"], { from: "user" });
|
||||||
|
|
||||||
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "exec.approval.resolve",
|
||||||
|
params: { id: "req-456", decision: "allow-always" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(runtimeLogs).toContain("✅ Exec approval allow-always submitted for req-456.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves approval with deny", async () => {
|
||||||
|
const { registerApproveCli } = await import("./approve-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerApproveCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["approve", "req-789", "deny"], { from: "user" });
|
||||||
|
|
||||||
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "exec.approval.resolve",
|
||||||
|
params: { id: "req-789", decision: "deny" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(runtimeLogs).toContain("✅ Exec approval deny submitted for req-789.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts decision aliases", async () => {
|
||||||
|
const { registerApproveCli } = await import("./approve-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerApproveCli(program);
|
||||||
|
|
||||||
|
// Test "always" alias for "allow-always"
|
||||||
|
await program.parseAsync(["approve", "req-alias", "always"], { from: "user" });
|
||||||
|
|
||||||
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { id: "req-alias", decision: "allow-always" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts 'once' alias for allow-once", async () => {
|
||||||
|
const { registerApproveCli } = await import("./approve-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerApproveCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["approve", "req-once", "once"], { from: "user" });
|
||||||
|
|
||||||
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { id: "req-once", decision: "allow-once" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts 'reject' alias for deny", async () => {
|
||||||
|
const { registerApproveCli } = await import("./approve-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerApproveCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["approve", "req-reject", "reject"], { from: "user" });
|
||||||
|
|
||||||
|
expect(callGateway).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { id: "req-reject", decision: "deny" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid decision", async () => {
|
||||||
|
const { registerApproveCli } = await import("./approve-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerApproveCli(program);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(["approve", "req-bad", "invalid-decision"], { from: "user" }),
|
||||||
|
).rejects.toThrow("__exit__:1");
|
||||||
|
|
||||||
|
expect(callGateway).not.toHaveBeenCalled();
|
||||||
|
expect(runtimeErrors).toContain(
|
||||||
|
'Invalid decision "invalid-decision". Use: allow-once, allow-always, or deny',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles gateway errors", async () => {
|
||||||
|
callGateway.mockRejectedValue(new Error("Gateway timeout"));
|
||||||
|
|
||||||
|
const { registerApproveCli } = await import("./approve-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerApproveCli(program);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(["approve", "req-fail", "allow-once"], { from: "user" }),
|
||||||
|
).rejects.toThrow("__exit__:1");
|
||||||
|
|
||||||
|
expect(runtimeErrors).toContain("❌ Failed to submit approval: Error: Gateway timeout");
|
||||||
|
});
|
||||||
|
});
|
||||||
89
src/cli/approve-cli.ts
Normal file
89
src/cli/approve-cli.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { callGateway } from "../gateway/call.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
|
import { theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
|
type ApproveDecision = "allow-once" | "allow-always" | "deny";
|
||||||
|
|
||||||
|
const DECISION_ALIASES: Record<string, ApproveDecision> = {
|
||||||
|
allow: "allow-once",
|
||||||
|
once: "allow-once",
|
||||||
|
"allow-once": "allow-once",
|
||||||
|
allowonce: "allow-once",
|
||||||
|
always: "allow-always",
|
||||||
|
"allow-always": "allow-always",
|
||||||
|
allowalways: "allow-always",
|
||||||
|
deny: "deny",
|
||||||
|
reject: "deny",
|
||||||
|
block: "deny",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeDecision(raw: string): ApproveDecision | null {
|
||||||
|
return DECISION_ALIASES[raw.toLowerCase().trim()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApproveCliOpts = {
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
timeout?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerApproveCli(program: Command) {
|
||||||
|
program
|
||||||
|
.command("approve <id> <decision>")
|
||||||
|
.description("Resolve an exec approval request")
|
||||||
|
.option("--url <url>", "Gateway WebSocket URL")
|
||||||
|
.option("--token <token>", "Gateway token (if required)")
|
||||||
|
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
() =>
|
||||||
|
`\n${theme.heading("Decisions:")}\n` +
|
||||||
|
` ${theme.command("allow-once")} Allow once (aliases: allow, once)\n` +
|
||||||
|
` ${theme.command("allow-always")} Allow always and add to allowlist (aliases: always)\n` +
|
||||||
|
` ${theme.command("deny")} Deny the request (aliases: reject, block)\n` +
|
||||||
|
`\n${theme.heading("Examples:")}\n` +
|
||||||
|
` ${theme.command("moltmate approve abc123 allow-once")}\n` +
|
||||||
|
` ${theme.command("moltmate approve abc123 always")}\n` +
|
||||||
|
` ${theme.command("moltmate approve abc123 deny")}\n` +
|
||||||
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approve", "docs.clawd.bot/cli/approve")}\n`,
|
||||||
|
)
|
||||||
|
.action(async (id: string, decision: string, opts: ApproveCliOpts) => {
|
||||||
|
const trimmedId = id.trim();
|
||||||
|
if (!trimmedId) {
|
||||||
|
defaultRuntime.error("Approval ID is required.");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDecision = normalizeDecision(decision);
|
||||||
|
if (!normalizedDecision) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
`Invalid decision "${decision}". Use: allow-once, allow-always, or deny`,
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await callGateway({
|
||||||
|
url: opts.url,
|
||||||
|
token: opts.token,
|
||||||
|
method: "exec.approval.resolve",
|
||||||
|
params: { id: trimmedId, decision: normalizedDecision },
|
||||||
|
timeoutMs: Number(opts.timeout ?? 10_000),
|
||||||
|
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||||
|
clientDisplayName: "CLI approve command",
|
||||||
|
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||||
|
});
|
||||||
|
|
||||||
|
defaultRuntime.log(`✅ Exec approval ${normalizedDecision} submitted for ${trimmedId}.`);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(`❌ Failed to submit approval: ${String(err)}`);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { sessionsCommand } from "../../commands/sessions.js";
|
|||||||
import { statusCommand } from "../../commands/status.js";
|
import { statusCommand } from "../../commands/status.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
|
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
|
||||||
|
import { registerApproveCli } from "../approve-cli.js";
|
||||||
import { registerBrowserCli } from "../browser-cli.js";
|
import { registerBrowserCli } from "../browser-cli.js";
|
||||||
import { registerConfigCli } from "../config-cli.js";
|
import { registerConfigCli } from "../config-cli.js";
|
||||||
import { registerMemoryCli, runMemoryStatus } from "../memory-cli.js";
|
import { registerMemoryCli, runMemoryStatus } from "../memory-cli.js";
|
||||||
@ -120,6 +121,10 @@ export const commandRegistry: CommandRegistration[] = [
|
|||||||
id: "config",
|
id: "config",
|
||||||
register: ({ program }) => registerConfigCli(program),
|
register: ({ program }) => registerConfigCli(program),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "approve",
|
||||||
|
register: ({ program }) => registerApproveCli(program),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "maintenance",
|
id: "maintenance",
|
||||||
register: ({ program }) => registerMaintenanceCommands(program),
|
register: ({ program }) => registerMaintenanceCommands(program),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user