import { CliUsageError, RuntimeApiError, csv, printData, rejectArgs, runCliAction, runtimeRequest, summaryLines, takeBooleanOption, takeFlag, takeOption, type RuntimeApiDeps, } from "./runtime-api"; import { clientIntegrationPath, validateAsideProfile } from "./integration-input"; import { asideProfileRecoveryLines } from "./aside-profile-recovery"; import type { IntegrationClientId } from "../integrations/registry"; const CLAUDE_USAGE = `Usage: ocx claude config [status] [--json] ocx claude config set [--enabled ] [--auth-mode ] [--system-env ] [--fast-mode ] [--auto-context ] [--compact-window ] [--context-accounting <1m|200k>] [--inject-agents ] [--small-fast-model ] [--model-map ] [--blocked-skills ] [--web-model ] [--web-backend ] [--vision-model ] [--vision-backend ] [--json] ocx claude config set --first-party [--json]`; const GROK_USAGE = `Usage: ocx grok [status] [--json] ocx grok [--json] ocx grok clear [--json] ocx grok apply [--json]`; const CLIENT_USAGE = `Usage: ocx integration client [status] [--client ] [--profile ] [--json] ocx integration client --client [--profile ] [--overwrite-conflict] [--json] ocx integration client history [--client ] [--profile ] [--json] ocx integration client restore --op [--client aside --profile ] [--confirm-drift] [--json] ocx integration client preview --client --operation [--profile ] [--json] ocx integration client restore --op --preview [--client aside --profile ] [--confirm-drift] [--json] ocx integration client history remove --op --yes [--client aside] [--profile ] [--json] ocx integration client sync --client aside [--json] enable/disable/restore accept --plan-fingerprint ; bound Aside operations require one profile. Droid enable/preview accept repeatable --reasoning-default or --clear-reasoning-defaults. Hermes/Command Code enable and apply/overwrite preview accept --model-names . --profile selects one Aside account-backed profile; omitted Aside toggles affect all profiles.`; function parseMap(raw: string): Record { if (raw === "-") return {}; const map: Record = {}; for (const pair of raw.split(",")) { const index = pair.indexOf("="); if (index <= 0 || index === pair.length - 1) throw new CliUsageError(`invalid model map entry "${pair}"; use from=to`, CLAUDE_USAGE); map[pair.slice(0, index).trim()] = pair.slice(index + 1).trim(); } return map; } export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { return runCliAction(async () => { const args = [...argv]; const action = (args.shift() ?? "status").toLowerCase(); const wantsJson = takeFlag(args, "--json"); if (action === "status" || action === "show") { rejectArgs(args, CLAUDE_USAGE); const result = await runtimeRequest("/api/claude-code", {}, deps); printData(result, wantsJson, summaryLines(result)); return; } if (action !== "set") throw new CliUsageError(`unknown Claude config command ${action}`, CLAUDE_USAGE); const body: Record = {}; const enabled = takeBooleanOption(args, "--enabled"); const firstParty = takeBooleanOption(args, "--first-party"); const authMode = takeOption(args, "--auth-mode"); const systemEnv = takeBooleanOption(args, "--system-env"); const fastMode = takeBooleanOption(args, "--fast-mode"); const autoContext = takeBooleanOption(args, "--auto-context"); const compact = takeOption(args, "--compact-window"); const accounting = takeOption(args, "--context-accounting"); const injectAgents = takeBooleanOption(args, "--inject-agents"); const smallFastModel = takeOption(args, "--small-fast-model"); const modelMap = takeOption(args, "--model-map"); const blockedSkills = takeOption(args, "--blocked-skills"); const webModel = takeOption(args, "--web-model"); const webBackend = takeOption(args, "--web-backend"); const visionModel = takeOption(args, "--vision-model"); const visionBackend = takeOption(args, "--vision-backend"); rejectArgs(args, CLAUDE_USAGE); if (enabled !== undefined) body.enabled = enabled; if (authMode !== undefined) body.authMode = authMode; if (systemEnv !== undefined) body.systemEnv = systemEnv; if (fastMode !== undefined) body.fastMode = fastMode; if (autoContext !== undefined) body.autoContext = autoContext; if (compact !== undefined) { if (compact === "default" || compact === "-") body.autoCompactWindow = null; else { const value = Number(compact.replace(/[_,]/g, "")); if (!Number.isInteger(value) || value <= 0) throw new CliUsageError("--compact-window must be a positive integer or default", CLAUDE_USAGE); body.autoCompactWindow = value; } } if (accounting !== undefined) { const value = accounting.toLowerCase(); if (value !== "1m" && value !== "200k") throw new CliUsageError("--context-accounting must be 1m or 200k", CLAUDE_USAGE); body.contextAccounting = value; } if (injectAgents !== undefined) body.injectAgents = injectAgents; if (smallFastModel !== undefined) body.smallFastModel = smallFastModel === "-" ? "" : smallFastModel; if (modelMap !== undefined) body.modelMap = parseMap(modelMap); if (blockedSkills !== undefined) body.blockedSkills = blockedSkills === "-" ? null : csv(blockedSkills); const sidecar = (model: string | undefined, backend: string | undefined): Record | undefined => { if (model === undefined && backend === undefined) return undefined; const result: Record = {}; if (model !== undefined) result.model = model === "-" ? "" : model; if (backend !== undefined) result.backend = backend === "-" ? null : backend; return result; }; const web = sidecar(webModel, webBackend); const vision = sidecar(visionModel, visionBackend); if (web) body.webSearchSidecar = web; if (vision) body.visionSidecar = vision; if (firstParty !== undefined) { if (Object.keys(body).length > 0) { throw new CliUsageError("--first-party must be set on its own (it writes Claude Code's settings file immediately)", CLAUDE_USAGE); } body.cliFirstParty = firstParty; } if (Object.keys(body).length === 0) throw new CliUsageError("at least one Claude setting is required", CLAUDE_USAGE); const result = await runtimeRequest("/api/claude-code", { method: "PUT", body: JSON.stringify(body) }, deps); const warnings = (result as { warnings?: unknown }).warnings; const retained = Array.isArray(warnings) && warnings.includes("shared_proxy_retained"); printData(result, wantsJson, [ "Claude Code settings updated.", // The route kept the shared proxy env because Claude Desktop may still rely on it. Say so, // or an operator who turned first-party off believes the local interception is gone. ...(retained ? ["Warning: Claude Desktop still uses the shared proxy settings. Run `ocx claude desktop apply --gateway` to release them."] : []), ]); }); } type GrokState = Record & { excluded?: string[] }; export async function handleGrokCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { return runCliAction(async () => { const args = [...argv]; const action = (args.shift() ?? "status").toLowerCase(); const wantsJson = takeFlag(args, "--json"); if (action === "status" || action === "show") { rejectArgs(args, GROK_USAGE); const result = await runtimeRequest("/api/grok", {}, deps); printData(result, wantsJson, summaryLines(result)); return; } if (action === "apply") { rejectArgs(args, GROK_USAGE); const result = await runtimeRequest("/api/grok/apply", { method: "POST" }, deps); printData(result, wantsJson, [String((result as Record).message ?? "Grok configuration applied.")]); return; } let excluded: string[]; if (action === "clear") excluded = []; else if (["exclude", "include", "set"].includes(action)) { const raw = args.shift(); if (!raw) throw new CliUsageError("comma-separated models are required", GROK_USAGE); const requested = csv(raw) ?? []; if (action === "set") excluded = requested; else { const state = await runtimeRequest("/api/grok", {}, deps); const current = new Set(state.excluded ?? []); for (const model of requested) action === "exclude" ? current.add(model) : current.delete(model); excluded = [...current].sort(); } } else throw new CliUsageError(`unknown Grok command ${action}`, GROK_USAGE); rejectArgs(args, GROK_USAGE); const result = await runtimeRequest("/api/grok/selection", { method: "PUT", body: JSON.stringify({ excluded }) }, deps); printData(result, wantsJson, [`Grok exclusions: ${excluded.join(", ") || "none"}`]); }); } /** The Raycast-only block the single-client route adds; see IntegrationStateEnvelope. */ interface RaycastStatusBlock { plan: string; aiDirPresent: boolean; } function raycastBlock(result: unknown): RaycastStatusBlock | null { if (!result || typeof result !== "object") return null; const block = (result as { raycast?: unknown }).raycast; if (!block || typeof block !== "object") return null; const { plan, aiDirPresent } = block as Partial; return typeof plan === "string" && typeof aiDirPresent === "boolean" ? { plan, aiDirPresent } : null; } /** * Text view of one client's status. * * Raycast carries an extra block, and the generic summary would print it as * three dotted keys. A `current` file that Raycast ignores for want of a Pro * subscription is the one fact this view must not bury, so `plan` gets its own * line and a missing `ai` folder gets the instruction that creates it. */ function singleClientStatusLines(result: unknown): string[] { const raycast = raycastBlock(result); if (!raycast) { const aside = (result as { clientId?: unknown } | null)?.clientId === "aside"; return [...summaryLines(result), ...(aside ? asideProfileRecoveryLines([result]) : [])]; } const rest = Object.fromEntries(Object.entries(result as Record).filter(([key]) => key !== "raycast")); const lines = [...summaryLines(rest), `plan: ${raycast.plan}`]; if (!raycast.aiDirPresent) { lines.push('On macOS or Windows, open Raycast → Settings → AI → "Reveal Providers Config" once so the ai folder exists.'); } return lines; } /** * The headless half of the client-integration toggle. * * Every safety property lives behind the management API — ownership, the * pre-write snapshot, the journal, the drift refusal — so this command is a * thin caller and deliberately re-implements none of it. That is also why * `restore` surfaces the drift refusal as an error telling the user to pass * `--confirm-drift` rather than retrying on their behalf: replacing edits a * user made after the snapshot is exactly the decision they have to make. */ export async function handleClientIntegrationCommand( argv: string[], deps: RuntimeApiDeps = {}, expectedClientId?: IntegrationClientId, ): Promise { if (expectedClientId !== undefined && argv.some(arg => ["--client", "--profile"].some(flag => arg === flag || arg.startsWith(`${flag}=`)))) { return runCliAction(async () => { throw new CliUsageError("client-specific restore does not accept --client or --profile", CLIENT_USAGE); }); } if ((argv[0] === "history" || argv[0] === "journal") && argv[1] === "remove") { const { handleIntegrationJournalRemove } = await import("./integration-journal"); return handleIntegrationJournalRemove(argv.slice(2), deps); } if (argv[0] === "sync") { const { handleIntegrationAsideSync } = await import("./integration-aside-sync"); return handleIntegrationAsideSync(argv.slice(1), deps); } if (argv[0] === "preview" || argv.some(arg => ["--preview", "--plan-fingerprint", "--reasoning-default", "--clear-reasoning-defaults", "--model-names"] .some(flag => arg === flag || arg.startsWith(`${flag}=`)))) { const { handleIntegrationPreviewCommand } = await import("./integration-preview"); return handleIntegrationPreviewCommand(argv, deps, expectedClientId); } return runCliAction(async () => { const args = [...argv]; const action = (args.shift() ?? "status").toLowerCase(); const wantsJson = takeFlag(args, "--json"); const profile = takeOption(args, "--profile"); if (action === "status" || action === "show" || action === "list") { const client = takeOption(args, "--client"); validateAsideProfile(profile, client, CLIENT_USAGE); rejectArgs(args, CLIENT_USAGE); const path = client ? clientIntegrationPath(client, profile) : "/api/client-integrations"; const result = await runtimeRequest(path, {}, deps); const rows = (result as { clients?: Array> }).clients; const profiles = (result as { profiles?: Array> }).profiles; printData(result, wantsJson, profiles ? profiles.length > 0 ? [ ...profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`), ...asideProfileRecoveryLines(profiles), ] : [String((result as { error?: string }).error ?? "No Aside profiles found.")] : rows /* * `supersededBy` is named here and not only in the single-client view * because this list is where a user looks to see that everything is * connected, and "current" alone is exactly the reassurance that hid a * client reading a file opencodex does not write. */ ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}${row.supersededBy ? " (client reads another file)" : ""}`) : singleClientStatusLines(result)); return; } if (action === "history" || action === "journal") { const client = takeOption(args, "--client"); validateAsideProfile(profile, client, CLIENT_USAGE); rejectArgs(args, CLIENT_USAGE); const path = client === "aside" ? `${clientIntegrationPath(client, profile)}/journal` : `/api/client-integrations/journal${client ? `?client=${encodeURIComponent(client)}` : ""}`; const result = await runtimeRequest(path, {}, deps); const operations = (result as { operations?: Array> }).operations ?? []; printData(result, wantsJson, operations.length === 0 ? ["No integration operations recorded yet."] : operations.map(row => { // `snapshot` is resolved against the disk by the route, so "expired" // here means the bytes are genuinely gone, not merely old. const backup = row.snapshot === "expired" ? "backup expired" : `op ${String(row.opId)}`; const owner = row.profileId === undefined ? String(row.clientId) : `${String(row.clientId)}:${String(row.profileId)}`; return `${String(row.at)} ${owner} ${String(row.kind)} (${backup})`; })); return; } if (action === "restore") { const opId = takeOption(args, "--op") ?? takeOption(args, "--op-id"); const confirmDrift = takeFlag(args, "--confirm-drift"); const client = takeOption(args, "--client"); validateAsideProfile(profile, client, CLIENT_USAGE); if (client !== undefined && profile === undefined) throw new CliUsageError("restore --client requires --profile", CLIENT_USAGE); rejectArgs(args, CLIENT_USAGE); if (!opId) throw new CliUsageError("--op is required", CLIENT_USAGE); const restorePath = expectedClientId !== undefined ? `${clientIntegrationPath(expectedClientId)}/restore` : profile === undefined ? "/api/client-integrations/restore" : `${clientIntegrationPath("aside", profile)}/restore`; const result = await runtimeRequest(restorePath, { method: "POST", ...(expectedClientId === undefined ? {} : { redirect: "error" as const }), body: JSON.stringify({ opId, confirmDrift, ...(expectedClientId === undefined ? {} : { expectedClientId }) }), }, deps); printData(result, wantsJson, [String((result as Record).message ?? "Restored.")]); return; } if (action !== "enable" && action !== "disable") { throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE); } const client = takeOption(args, "--client"); validateAsideProfile(profile, client, CLIENT_USAGE); /* * The conflict escape hatch, spelled the way `restore --confirm-drift` is: the * refusal is the default and the waiver has to be typed. * * Without it the dashboard could resolve a conflict and the CLI could not, * which strands exactly the user who cannot open a browser -- an SSH session, * or an agent driving the proxy. That dead end is the reason the overwrite * path exists at all. */ const overwriteConflict = takeFlag(args, "--overwrite-conflict"); rejectArgs(args, CLIENT_USAGE); if (!client) throw new CliUsageError("--client is required", CLIENT_USAGE); /* * Refused here rather than forwarded. The route answers 400 for this pair, but * a local usage error names the flag that is wrong, where the route's reply * arrives as a generic failed request. */ if (overwriteConflict && action === "disable") { throw new CliUsageError("--overwrite-conflict applies only to enable", CLIENT_USAGE); } const result = await runtimeRequest(clientIntegrationPath(client, profile), { method: "PUT", // Sent only when asked for, so a proxy on an older build sees the request it // has always seen rather than an unknown field. body: JSON.stringify(overwriteConflict ? { enabled: true, overwriteConflict: true } : { enabled: action === "enable" }), }, deps); const batch = result as { ok?: boolean; message?: string; results?: Array> }; printData(result, wantsJson, batch.results ? batch.results.map(row => `aside:${String(row.profileId)} ${String(row.message ?? (row.ok ? "updated" : "refused"))}${row.residual === true ? " Recovery did not finish." : ""}${typeof row.snapshotPath === "string" ? ` Backup: ${row.snapshotPath}` : ""}`) : [String(batch.message ?? `${client} ${action}d.`)]); if (batch.ok === false) throw new RuntimeApiError(batch.message ?? "Some Aside profiles could not be updated", 207, result); }); } export const INTEGRATION_USAGE = { claude: CLAUDE_USAGE, grok: GROK_USAGE, client: CLIENT_USAGE }; const ZCODE_USAGE = `Usage: ocx zcode [status] [--json] ocx zcode [--json] ocx zcode history [--json] ocx zcode restore --op [--confirm-drift] [--json]`; /** * Thin alias over the client-integration surface for ZCode (Z.ai's desktop * client). ZCode is a GUI app with no launch surface to wrap, so unlike * `ocx mcode` there is no exec step: connecting the managed provider block is * the whole integration, and every safety property (ownership, snapshots, * journal, drift refusal) stays behind the shared management API. ZCode reads * its config at startup, so enable/disable print a restart reminder. */ export async function handleZcodeCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { const args = [...argv]; // Find the first non-flag token so `ocx zcode --json enable` still enables. const verbIndex = args.findIndex(arg => !arg.startsWith("-")); const action = (verbIndex === -1 ? "status" : args[verbIndex]).toLowerCase(); const known = ["status", "show", "list", "enable", "disable", "history", "journal", "restore"]; if (!known.includes(action)) { console.error(`unknown zcode command ${action}`); console.error(ZCODE_USAGE); return 2; } const rest = verbIndex === -1 ? args : [...args.slice(0, verbIndex), ...args.slice(verbIndex + 1)]; // `restore` addresses an operation id, not a client, so nothing is injected. const forwarded = action === "restore" ? [action, ...rest] : [action, ...rest, "--client", "zcode"]; const code = await handleClientIntegrationCommand(forwarded, deps); if (code === 0 && (action === "enable" || action === "disable")) { console.error("Restart ZCode to pick up the provider change."); } return code; } export async function handleClaudeInterceptCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { return runCliAction(async () => { const args = [...argv]; const action = args.shift(); const wantsJson = takeFlag(args, "--json"); const usage = "Usage: ocx claude intercept start [--json]"; rejectArgs(args, usage); if (action !== "start") throw new CliUsageError("Expected start", usage); const result = await runtimeRequest("/api/claude-intercept/start", { method: "POST" }, deps); printData(result, wantsJson, summaryLines(result)); }); } const COMMANDCODE_USAGE = "Usage: ocx commandcode [status|show|list|enable|disable|history|journal|restore] [--json]"; export async function handleCommandcodeCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { const args = [...argv]; const verbIndex = args.findIndex(arg => !arg.startsWith("-")); const action = (verbIndex === -1 ? "status" : args[verbIndex]).toLowerCase(); const known = ["status", "show", "list", "enable", "disable", "history", "journal", "restore"]; if (!known.includes(action)) { console.error(`unknown commandcode command ${action}`); console.error(COMMANDCODE_USAGE); return 2; } const rest = verbIndex === -1 ? args : [...args.slice(0, verbIndex), ...args.slice(verbIndex + 1)]; const forwarded = action === "restore" ? [action, ...rest] : [action, ...rest, "--client", "commandcode"]; const code = await handleClientIntegrationCommand(forwarded, deps, action === "restore" ? "commandcode" : undefined); if (code === 0 && (action === "enable" || action === "disable")) { console.error("Command Code reads providers on startup."); } return code; }