import type { Command } from "commander"; import { listPairingChannels, notifyPairingApproved } from "../channels/plugins/pairing.js"; import { normalizeChannelId } from "../channels/plugins/index.js"; import { loadConfig } from "../config/config.js"; import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; import { approveChannelPairingCode, listChannelPairingRequests, type PairingChannel, } from "../pairing/pairing-store.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatCliCommand } from "./command-format.js"; /** Parse channel, allowing extension channels not in core registry. */ function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel { const value = ( typeof raw === "string" ? raw : typeof raw === "number" || typeof raw === "boolean" ? String(raw) : "" ) .trim() .toLowerCase(); if (!value) throw new Error("Channel required"); const normalized = normalizeChannelId(value); if (normalized) { if (!channels.includes(normalized as PairingChannel)) { throw new Error(`Channel ${normalized} does not support pairing`); } return normalized as PairingChannel; } // Allow extension channels: validate format but don't require registry if (/^[a-z][a-z0-9_-]{0,63}$/.test(value)) return value as PairingChannel; throw new Error(`Invalid channel: ${value}`); } async function notifyApproved(channel: PairingChannel, id: string) { const cfg = loadConfig(); await notifyPairingApproved({ channelId: channel, id, cfg }); } export function registerPairingCli(program: Command) { const channels = listPairingChannels(); const pairing = program .command("pairing") .description("Secure DM pairing (approve inbound requests)") .addHelpText( "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`, ); pairing .command("list") .description("List pending pairing requests") .option("--channel ", `Channel (${channels.join(", ")})`) .argument("[channel]", `Channel (${channels.join(", ")})`) .option("--json", "Print JSON", false) .action(async (channelArg, opts) => { const channelRaw = opts.channel ?? channelArg; if (!channelRaw) { throw new Error( `Channel required. Use --channel or pass it as the first argument (expected one of: ${channels.join(", ")})`, ); } const channel = parseChannel(channelRaw, channels); const requests = await listChannelPairingRequests(channel); if (opts.json) { defaultRuntime.log(JSON.stringify({ channel, requests }, null, 2)); return; } if (requests.length === 0) { defaultRuntime.log(theme.muted(`No pending ${channel} pairing requests.`)); return; } const idLabel = resolvePairingIdLabel(channel); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); defaultRuntime.log( `${theme.heading("Pairing requests")} ${theme.muted(`(${requests.length})`)}`, ); defaultRuntime.log( renderTable({ width: tableWidth, columns: [ { key: "Code", header: "Code", minWidth: 10 }, { key: "ID", header: idLabel, minWidth: 12, flex: true }, { key: "Meta", header: "Meta", minWidth: 8, flex: true }, { key: "Requested", header: "Requested", minWidth: 12 }, ], rows: requests.map((r) => ({ Code: r.code, ID: r.id, Meta: r.meta ? JSON.stringify(r.meta) : "", Requested: r.createdAt, })), }).trimEnd(), ); }); pairing .command("approve") .description("Approve a pairing code and allow that sender") .option("--channel ", `Channel (${channels.join(", ")})`) .argument("", "Pairing code (or channel when using 2 args)") .argument("[code]", "Pairing code (when channel is passed as the 1st arg)") .option("--notify", "Notify the requester on the same channel", false) .action(async (codeOrChannel, code, opts) => { const channelRaw = opts.channel ?? codeOrChannel; const resolvedCode = opts.channel ? codeOrChannel : code; if (!opts.channel && !code) { throw new Error( `Usage: ${formatCliCommand("clawdbot pairing approve ")} (or: ${formatCliCommand("clawdbot pairing approve --channel ")})`, ); } if (opts.channel && code != null) { throw new Error( `Too many arguments. Use: ${formatCliCommand("clawdbot pairing approve --channel ")}`, ); } const channel = parseChannel(channelRaw, channels); const approved = await approveChannelPairingCode({ channel, code: String(resolvedCode), }); if (!approved) { throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`); } defaultRuntime.log( `${theme.success("Approved")} ${theme.muted(channel)} sender ${theme.command(approved.id)}.`, ); if (!opts.notify) return; await notifyApproved(channel, approved.id).catch((err) => { defaultRuntime.log(theme.warn(`Failed to notify requester: ${String(err)}`)); }); }); }