import type { Command } from "commander"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { startGatewayServer } from "../gateway/server.js"; import { info, setVerbose } from "../globals.js"; import { GatewayLockError } from "../infra/gateway-lock.js"; import { defaultRuntime } from "../runtime.js"; import { createDefaultDeps } from "./deps.js"; import { forceFreePort } from "./ports.js"; type GatewayRpcOpts = { url?: string; token?: string; timeout?: string; expectFinal?: boolean; }; const gatewayCallOpts = (cmd: Command) => cmd .option("--url ", "Gateway WebSocket URL", "ws://127.0.0.1:18789") .option("--token ", "Gateway token (if required)") .option("--timeout ", "Timeout in ms", "10000") .option("--expect-final", "Wait for final response (agent)", false); const callGatewayCli = async ( method: string, opts: GatewayRpcOpts, params?: unknown, ) => callGateway({ url: opts.url, token: opts.token, method, params, expectFinal: Boolean(opts.expectFinal), timeoutMs: Number(opts.timeout ?? 10_000), clientName: "cli", mode: "cli", }); export function registerGatewayCli(program: Command) { const gateway = program .command("gateway") .description("Run the WebSocket Gateway") .option("--port ", "Port for the gateway WebSocket", "18789") .option( "--webchat-port ", "Port for the loopback WebChat HTTP server (default 18788)", ) .option( "--token ", "Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)", ) .option( "--force", "Kill any existing listener on the target port before starting", false, ) .option("--verbose", "Verbose logging to stdout/stderr", false) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); const port = Number.parseInt(String(opts.port ?? "18789"), 10); if (Number.isNaN(port) || port <= 0) { defaultRuntime.error("Invalid port"); defaultRuntime.exit(1); } const webchatPort = opts.webchatPort ? Number.parseInt(String(opts.webchatPort), 10) : undefined; if ( webchatPort !== undefined && (Number.isNaN(webchatPort) || webchatPort <= 0) ) { defaultRuntime.error("Invalid webchat port"); defaultRuntime.exit(1); } if (opts.force) { try { const killed = forceFreePort(port); if (killed.length === 0) { defaultRuntime.log(info(`Force: no listeners on port ${port}`)); } else { for (const proc of killed) { defaultRuntime.log( info( `Force: killed pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""} on port ${port}`, ), ); } await new Promise((resolve) => setTimeout(resolve, 200)); } } catch (err) { defaultRuntime.error(`Force: ${String(err)}`); defaultRuntime.exit(1); return; } } if (opts.token) { process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token); } let server: Awaited> | null = null; let shuttingDown = false; let forceExitTimer: ReturnType | null = null; const onSigterm = () => shutdown("SIGTERM"); const onSigint = () => shutdown("SIGINT"); const shutdown = (signal: string) => { // Ensure we don't leak listeners across restarts/tests. process.removeListener("SIGTERM", onSigterm); process.removeListener("SIGINT", onSigint); if (shuttingDown) { defaultRuntime.log( info(`gateway: received ${signal} during shutdown; exiting now`), ); defaultRuntime.exit(0); } shuttingDown = true; defaultRuntime.log(info(`gateway: received ${signal}; shutting down`)); // Avoid hanging forever if a provider task ignores abort. forceExitTimer = setTimeout(() => { defaultRuntime.error( "gateway: shutdown timed out; exiting without full cleanup", ); defaultRuntime.exit(0); }, 5000); void (async () => { try { await server?.close(); } catch (err) { defaultRuntime.error(`gateway: shutdown error: ${String(err)}`); } finally { if (forceExitTimer) clearTimeout(forceExitTimer); defaultRuntime.exit(0); } })(); }; process.once("SIGTERM", onSigterm); process.once("SIGINT", onSigint); try { server = await startGatewayServer(port, { webchatPort }); } catch (err) { if (err instanceof GatewayLockError) { defaultRuntime.error(`Gateway failed to start: ${err.message}`); defaultRuntime.exit(1); return; } defaultRuntime.error(`Gateway failed to start: ${String(err)}`); defaultRuntime.exit(1); } // Keep process alive await new Promise(() => {}); }); gatewayCallOpts( gateway .command("call") .description("Call a Gateway method and print JSON") .argument( "", "Method name (health/status/system-presence/send/agent/cron.*)", ) .option("--params ", "JSON object string for params", "{}") .action(async (method, opts) => { try { const params = JSON.parse(String(opts.params ?? "{}")); const result = await callGatewayCli(method, opts, params); defaultRuntime.log(JSON.stringify(result, null, 2)); } catch (err) { defaultRuntime.error(`Gateway call failed: ${String(err)}`); defaultRuntime.exit(1); } }), ); gatewayCallOpts( gateway .command("health") .description("Fetch Gateway health") .action(async (opts) => { try { const result = await callGatewayCli("health", opts); defaultRuntime.log(JSON.stringify(result, null, 2)); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); } }), ); gatewayCallOpts( gateway .command("status") .description("Fetch Gateway status") .action(async (opts) => { try { const result = await callGatewayCli("status", opts); defaultRuntime.log(JSON.stringify(result, null, 2)); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); } }), ); gatewayCallOpts( gateway .command("send") .description("Send a message via the Gateway") .requiredOption("--to ", "Destination (E.164 or jid)") .requiredOption("--message ", "Message text") .option("--media-url ", "Optional media URL") .option("--idempotency-key ", "Idempotency key") .action(async (opts) => { try { const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey(); const result = await callGatewayCli("send", opts, { to: opts.to, message: opts.message, mediaUrl: opts.mediaUrl, idempotencyKey, }); defaultRuntime.log(JSON.stringify(result, null, 2)); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); } }), ); gatewayCallOpts( gateway .command("agent") .description("Run an agent turn via the Gateway (waits for final)") .requiredOption("--message ", "User message") .option("--to ", "Destination") .option("--session-id ", "Session id") .option("--thinking ", "Thinking level") .option("--deliver", "Deliver response", false) .option("--timeout-seconds ", "Agent timeout seconds") .option("--idempotency-key ", "Idempotency key") .action(async (opts) => { try { const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey(); const result = await callGatewayCli( "agent", { ...opts, expectFinal: true }, { message: opts.message, to: opts.to, sessionId: opts.sessionId, thinking: opts.thinking, deliver: Boolean(opts.deliver), timeout: opts.timeoutSeconds ? Number.parseInt(String(opts.timeoutSeconds), 10) : undefined, idempotencyKey, }, ); defaultRuntime.log(JSON.stringify(result, null, 2)); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); } }), ); // Build default deps (keeps parity with other commands; future-proofing). void createDefaultDeps(); }