This commit is contained in:
Robby 2026-01-29 21:53:38 -05:00 committed by GitHub
commit ac4996bbd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 254 additions and 0 deletions

160
src/cli/approve-cli.test.ts Normal file
View 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
View 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);
}
});
}

View File

@ -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),