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 { defaultRuntime } from "../../runtime.js";
|
||||
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
|
||||
import { registerApproveCli } from "../approve-cli.js";
|
||||
import { registerBrowserCli } from "../browser-cli.js";
|
||||
import { registerConfigCli } from "../config-cli.js";
|
||||
import { registerMemoryCli, runMemoryStatus } from "../memory-cli.js";
|
||||
@ -120,6 +121,10 @@ export const commandRegistry: CommandRegistration[] = [
|
||||
id: "config",
|
||||
register: ({ program }) => registerConfigCli(program),
|
||||
},
|
||||
{
|
||||
id: "approve",
|
||||
register: ({ program }) => registerApproveCli(program),
|
||||
},
|
||||
{
|
||||
id: "maintenance",
|
||||
register: ({ program }) => registerMaintenanceCommands(program),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user