diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index c884bb2e5d..07c8e7d20a 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -403,7 +403,7 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi", "raycast"])); + }, ["mcode", "pi", "raycast", "commandcode"])); } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } @@ -829,6 +829,14 @@ const commandRunners: Record = { const { handleZcodeCommand } = await import("./integrations"); return await handleZcodeCommand(deps.args.slice(1)); }, + commandcode: async deps => { + const { handleCommandcodeCommand } = await import("./integrations"); + return await handleCommandcodeCommand(deps.args.slice(1)); + }, + cmd: async deps => { + const { handleCommandcodeCommand } = await import("./integrations"); + return await handleCommandcodeCommand(deps.args.slice(1)); + }, help: async () => { printUsage(); return 0; diff --git a/src/cli/help.ts b/src/cli/help.ts index 0cd6bec4dc..227c5b0e0f 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -77,7 +77,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (13 clients) + ocx export --client Print a client config wired to the running proxy (14 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection @@ -89,6 +89,8 @@ Usage: ocx mcode [args...] Launch MiniMax Code through its managed provider ocx mmx text [args] Launch MiniMax CLI text through the proxy ocx zcode [sub] Connect ZCode to the proxy (managed provider) + ocx commandcode [sub] Connect Command Code CLI to the proxy (managed provider) + ocx cmd [sub] Alias of ocx commandcode ocx help [command] Show help ocx --version | -v Print version diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index 89ab3ee046..6c25191f2e 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -347,3 +347,24 @@ export async function handleZcodeCommand(argv: string[], deps: RuntimeApiDeps = } return code; } + +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); + if (code === 0 && (action === "enable" || action === "disable")) { + console.error("Command Code reads providers on startup."); + } + return code; +} diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 467a0f7971..aaa904f802 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -287,8 +287,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", @@ -399,6 +399,21 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Select OpenCodex Proxy// from ZCode's model picker.", ], }, + { + name: "commandcode", + usage: "ocx commandcode [status|show|list|enable|disable|history|restore] [--json]", + summary: "Connect Command Code CLI to the proxy via its managed provider.", + details: [ + "Alias of ocx integration client --client commandcode.", + "enable writes the managed provider.opencodex block into ~/.commandcode/providers.json; disable removes only that block.", + "Command Code reads providers on startup.", + ], + }, + { + name: "cmd", + usage: "ocx cmd [status|show|list|enable|disable|history|restore] [--json]", + summary: "Alias of ocx commandcode.", + }, { name: "restart", usage: "ocx restart", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 8fb42f311d..29cd4b1537 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -48,6 +48,17 @@ import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./conf import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode"; import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode"; import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast"; +import { + buildCommandCodeClientConfig, + summarizeCommandCode, + buildCommandCodeContribution, + type CommandCodeGeneratedConfig, + type CommandCodeModelEntry, + type CommandCodeProviderBlock, +} from "./config-export/commandcode"; + +export type { CommandCodeGeneratedConfig, CommandCodeModelEntry, CommandCodeProviderBlock }; +export { buildCommandCodeClientConfig, summarizeCommandCode, buildCommandCodeContribution }; @@ -438,6 +449,16 @@ export function zcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(zcodeHomeDir(env, home), "v2", "config.json"); } +export function commandCodeHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const override = env.COMMANDCODE_HOME?.trim(); + if (override) return absoluteClientPath(override, home, "COMMANDCODE_HOME"); + return join(home, ".commandcode"); +} + +export function commandCodeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(commandCodeHomeDir(env, home), "providers.json"); +} + /** * Prime Agent resolves its agent directory from `PRIME_AGENT_CODING_AGENT_DIR` * — the brand-derived spelling of the `PI_CODING_AGENT_DIR` that `ompAgentDir` @@ -1245,6 +1266,18 @@ export const EXPORT_CLIENTS: Record = { // remote binds refuse — same reasoning as MCode. loopbackOnly: true, }, + commandcode: { + id: "commandcode", + filename: "providers.json", + destination: env => commandCodeConfigPath(env), + apiKeyEnv: "OPENCODEX_COMMANDCODE_API_KEY", + exportHint: "Command Code reads service token from serviceApiTokenFilePath or loopback placeholder.", + build: buildCommandCodeClientConfig, + format: "json", + summarize: summarizeCommandCode, + buildContribution: buildCommandCodeContribution, + loopbackOnly: true, + }, prime: { id: "prime", filename: "prime-models.json", diff --git a/src/clients/config-export/commandcode.ts b/src/clients/config-export/commandcode.ts new file mode 100644 index 0000000000..a7b27dd56f --- /dev/null +++ b/src/clients/config-export/commandcode.ts @@ -0,0 +1,65 @@ +// Command Code config export. +import { existsSync } from "node:fs"; +import type { ExportContext, ManagedContribution } from "./contracts"; +import { normalizeExportModels, authoritativeContextWindow, singleFragment } from "./model-metadata"; +import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"; +import { serviceApiTokenFilePath } from "../../lib/service-secrets"; +import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; + +export interface CommandCodeModelEntry { + reasoningEfforts?: string[]; + contextWindow?: number; +} + +export interface CommandCodeProviderBlock { + name: "OpenCodex"; + api: "openai-completions"; + baseURL: string; + apiKey: string; + models: Record; +} + +export interface CommandCodeGeneratedConfig { + provider: Record; +} + +export function buildCommandCodeClientConfig(ctx: ExportContext): CommandCodeGeneratedConfig { + const models: Record = {}; + for (const model of normalizeExportModels(ctx.models)) { + const entry: CommandCodeModelEntry = {}; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.contextWindow = context; + } + const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts) + ?.filter(effort => ["low", "medium", "high", "xhigh", "max"].includes(effort)); + if (efforts && efforts.length > 0) { + entry.reasoningEfforts = efforts; + } + models[model.namespaced] = entry; + } + const tokenPath = serviceApiTokenFilePath(); + const apiKey = existsSync(tokenPath) ? `!cat ${tokenPath}` : LOOPBACK_API_KEY_PLACEHOLDER; + return { + provider: { + [OPENCODE_PROVIDER_ID]: { + name: "OpenCodex", + api: "openai-completions", + baseURL: ctx.baseUrl.replace(/\/v1\/?$/, "") + "/v1", + apiKey, + models, + }, + }, + }; +} + +export function summarizeCommandCode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = Object.values((document as CommandCodeGeneratedConfig | undefined)?.provider?.[OPENCODE_PROVIDER_ID]?.models ?? {}); + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; +} + +export function buildCommandCodeContribution(ctx: ExportContext): ManagedContribution { + const doc = buildCommandCodeClientConfig(ctx); + return singleFragment("commandcode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]); +} + diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index c888a4c257..9c05b34501 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -94,6 +94,7 @@ export type ExportClientId = | "zcode" | "prime" | "aside" + | "commandcode" | "raycast"; export interface ExportClientSpec { diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index f5780f4f98..430cb76782 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -39,6 +39,8 @@ import { raycastConfigPath, zcodeConfigPath, zcodeHomeDir, + commandCodeConfigPath, + commandCodeHomeDir, type ExportClientId, } from "../clients/config-export"; @@ -227,6 +229,12 @@ export const INTEGRATION_CLIENTS: Record zcodeConfigPath(env, home), detectDir: (env = process.env, home = homedir()) => zcodeHomeDir(env, home), }, + commandcode: { + id: "commandcode", + configPath: (env = process.env, home = homedir()) => commandCodeConfigPath(env, home), + detectDir: (env = process.env, home = homedir()) => commandCodeHomeDir(env, home), + writerLock: { suffix: ".lock" }, + }, prime: { id: "prime", configPath: (env = process.env, home = homedir()) => primeConfigPath(env, home), diff --git a/tests/clients/command-code-client.test.ts b/tests/clients/command-code-client.test.ts new file mode 100644 index 0000000000..60064b3c77 --- /dev/null +++ b/tests/clients/command-code-client.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + ClientPathError, + EXPORT_CLIENTS, + LOOPBACK_API_KEY_PLACEHOLDER, + OPENCODE_PROVIDER_ID, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + commandCodeConfigPath, + commandCodeHomeDir, + type CommandCodeGeneratedConfig, + type ExportContext, +} from "../../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import type { OcxConfig } from "../../src/types"; + +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +function context(): ExportContext { + return { + baseUrl: "http://127.0.0.1:10100/v1", + config: CONFIG, + models: [ + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, inputModalities: ["text", "image"] }, + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol", contextWindow: 922_000, reasoningEfforts: ["low", "medium", "high"] }, + { namespaced: "google-antigravity/gemini-3.8-flash", provider: "google-antigravity", id: "gemini-3.8-flash", contextWindow: 1_048_576, reasoningEfforts: ["low", "medium", "high"] }, + { namespaced: "mystery/model", provider: "mystery", id: "model" }, + ], + }; +} + +describe("Command Code client config", () => { + test("generates valid JSON with provider.opencodex block", () => { + const built = buildClientConfigText("commandcode", context()); + expect(built.format).toBe("json"); + expect(JSON.parse(built.text)).toEqual(built.document as never); + }); + + test("adds only provider.opencodex, wired to the loopback proxy", () => { + const document = buildClientConfig("commandcode", context()) as CommandCodeGeneratedConfig; + expect(Object.keys(document)).toEqual(["provider"]); + expect(Object.keys(document.provider)).toEqual([OPENCODE_PROVIDER_ID]); + const provider = document.provider[OPENCODE_PROVIDER_ID]!; + expect(provider.name).toBe("OpenCodex"); + expect(provider.api).toBe("openai-completions"); + expect(provider.baseURL).toBe("http://127.0.0.1:10100/v1"); + expect(provider.apiKey).toBeDefined(); + }); + + test("emits contextWindow and reasoningEfforts correctly without guessing", () => { + const document = buildClientConfig("commandcode", context()) as CommandCodeGeneratedConfig; + const provider = document.provider[OPENCODE_PROVIDER_ID]!; + const gemini = provider.models["google-antigravity/gemini-3.8-flash"]; + expect(gemini).toBeDefined(); + expect(gemini?.contextWindow).toBe(1_048_576); + expect(gemini?.reasoningEfforts).toEqual(["low", "medium", "high"]); + + const mystery = provider.models["mystery/model"]; + expect(mystery).toBeDefined(); + expect(mystery?.contextWindow).toBeUndefined(); + expect(mystery?.reasoningEfforts).toBeUndefined(); + }); + + test("the contribution owns exactly the provider.opencodex path under its own id", () => { + const contribution = buildClientContribution("commandcode", context()); + expect(contribution.clientId).toBe("commandcode"); + expect(contribution.fragments.map(f => f.path)).toEqual([["provider", OPENCODE_PROVIDER_ID]]); + }); + + test("resolves the home directory override and the documented destination", () => { + expect(commandCodeHomeDir({}, "/home/u")).toBe(join("/home/u", ".commandcode")); + expect(commandCodeConfigPath({}, "/home/u")).toBe(join("/home/u", ".commandcode", "providers.json")); + expect(commandCodeConfigPath({ COMMANDCODE_HOME: "/elsewhere" }, "/home/u")).toBe(join("/elsewhere", "providers.json")); + expect(commandCodeConfigPath({ COMMANDCODE_HOME: "~/alt" }, "/home/u")).toBe(join("/home/u", "alt", "providers.json")); + expect(() => commandCodeConfigPath({ COMMANDCODE_HOME: "relative" }, "/home/u")).toThrow(ClientPathError); + }); + + test("detects installation by the .commandcode directory the override names", () => { + const spec = INTEGRATION_CLIENTS.commandcode; + expect(spec.detectDir({}, "/home/u")).toBe(join("/home/u", ".commandcode")); + expect(spec.detectDir({ COMMANDCODE_HOME: "/elsewhere" } as NodeJS.ProcessEnv, "/home/u")).toBe("/elsewhere"); + }); + + test("ships as a loopback-only integration with proper export metadata", () => { + const spec = EXPORT_CLIENTS.commandcode; + expect(spec.id).toBe("commandcode"); + expect(spec.filename).toBe("providers.json"); + expect(spec.format).toBe("json"); + expect(spec.loopbackOnly).toBe(true); + }); +}); +