Skip to content

Commit 16d4d30

Browse files
committed
feat(export): add the ocx export CLI command
Phase 020 of devlog/_plan/260731_client_config_export. `ocx export --client <opencode|pi>` prints the client config. With --json stdout is the config and nothing else, so an agent can pipe it; without it, the JSON is followed by the destination path, the merge warning, the env line, and a count of models shipping without context limits. --out writes the file but refuses to clobber an existing one without --force, since the obvious target is a populated config whose other providers would be lost. Disabled rows are filtered here, at the /api/models boundary: the export core dedupes and sorts but cannot see visibility state. A non-array models payload is an error rather than an empty-model config. Also corrects two example paths in the plan docs that tripped privacy:scan.
1 parent 091886f commit 16d4d30

5 files changed

Lines changed: 507 additions & 1 deletion

File tree

devlog/_plan/260731_client_config_export/020_cli_surface.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ $ ocx export --client opencode
4141
...
4242
}
4343
44-
Destination: /Users/you/.config/opencode/opencode.json
44+
Destination: ~/.config/opencode/opencode.json
4545
Merge this provider block into that file; do not replace it.
4646
Before launching: export OPENCODEX_OPENCODE_API_KEY=<your ocx_... key>
4747
19 models; 2 omit context limits (the client applies its own defaults).

src/cli/export-command.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* `ocx export --client <opencode|pi>` — print a client config for the live proxy.
3+
*
4+
* Two consumers, one payload (devlog 260731_client_config_export/020):
5+
*
6+
* - **Agent** (`--json`): stdout is exactly the client config JSON and nothing else, so
7+
* `ocx export --client pi --json > models.json` is safe to pipe. Every diagnostic —
8+
* including the `--out` write note — goes to stderr.
9+
* - **Human** (no flag): the JSON leads, then the destination path, the merge warning,
10+
* the env export line, and the model/degraded counts.
11+
*
12+
* The command never writes the user's real config path. `--out` is an explicit target and
13+
* refuses to clobber an existing file without `--force`, because the common mistake
14+
* (`--out ~/.config/opencode/opencode.json`) would silently destroy other providers.
15+
*
16+
* Serialization itself belongs to src/clients/config-export.ts; this module only resolves
17+
* the base URL, filters the catalog, and renders. No secret is ever serialized: the config
18+
* carries the client's documented env reference and the real key stays in the environment.
19+
*/
20+
import { writeFileSync } from "node:fs";
21+
import { loadConfig } from "../config";
22+
import {
23+
EXPORT_CLIENTS,
24+
EXPORT_CLIENT_IDS,
25+
buildClientConfig,
26+
isExportClientId,
27+
opencodeProxyBaseUrl,
28+
type ExportClientId,
29+
type ExportModel,
30+
} from "../clients/config-export";
31+
import { opencodeCatalogFromProxyRows, type OpencodeProxyModelRow } from "./opencode";
32+
import type { OcxConfig } from "../types";
33+
import {
34+
CliUsageError,
35+
RuntimeApiError,
36+
printData,
37+
rejectArgs,
38+
runCliAction,
39+
runtimeBaseUrl,
40+
runtimeRequest,
41+
takeFlag,
42+
takeOption,
43+
type RuntimeApiDeps,
44+
} from "./runtime-api";
45+
46+
const USAGE = `Usage:
47+
ocx export --client <${EXPORT_CLIENT_IDS.join("|")}> [--json] [--out <path>] [--force]`;
48+
49+
export interface ExportCommandDeps extends RuntimeApiDeps {
50+
/** Live config seam; tests inject a fixture instead of reading the user's config. */
51+
configImpl?: () => OcxConfig;
52+
}
53+
54+
/**
55+
* `/api/models` row plus the modality list Pi consumes. The launcher's row type predates
56+
* the Pi exporter and stops at the fields OpenCode needs.
57+
*/
58+
type ExportProxyModelRow = OpencodeProxyModelRow & { inputModalities?: string[] };
59+
60+
/** Same authoritativeness rule the serializers apply, for the degraded-count line. */
61+
function hasContextLimit(model: ExportModel): boolean {
62+
return typeof model.contextWindow === "number"
63+
&& Number.isFinite(model.contextWindow)
64+
&& model.contextWindow > 0;
65+
}
66+
67+
/**
68+
* Export rows from proxy `/api/models` rows.
69+
*
70+
* `opencodeCatalogFromProxyRows` owns the visibility rules (drop `disabled`, drop dupes,
71+
* drop native under Codex Direct) — the export core does none of that, so a row filtered
72+
* here is the only thing keeping a disabled model out of a client's picker. Modalities are
73+
* re-joined by `namespaced` because the launcher's catalog type does not carry them.
74+
*/
75+
export function exportModelsFromProxyRows(
76+
rows: readonly ExportProxyModelRow[],
77+
config: OcxConfig,
78+
): ExportModel[] {
79+
const modalities = new Map<string, string[]>();
80+
for (const row of rows) {
81+
const namespaced = row.namespaced?.trim();
82+
if (namespaced && Array.isArray(row.inputModalities) && row.inputModalities.length > 0) {
83+
if (!modalities.has(namespaced)) modalities.set(namespaced, [...row.inputModalities]);
84+
}
85+
}
86+
return opencodeCatalogFromProxyRows(rows, config).map(entry => {
87+
const model: ExportModel = {
88+
namespaced: entry.namespaced,
89+
provider: entry.provider ?? (entry.native ? "openai" : "routed"),
90+
id: entry.id ?? entry.namespaced,
91+
};
92+
if (entry.native) model.native = true;
93+
if (entry.displayName) model.displayName = entry.displayName;
94+
if (entry.contextWindow !== undefined) model.contextWindow = entry.contextWindow;
95+
const input = modalities.get(entry.namespaced);
96+
if (input) model.inputModalities = input;
97+
return model;
98+
});
99+
}
100+
101+
/**
102+
* `http://host:port/v1` for the proxy that is actually listening.
103+
*
104+
* `runtimeBaseUrl` is the identity-checked `findLiveProxy` probe the launcher uses, and it
105+
* already throws the "Start it with: ocx start" error when nothing answers — so a config
106+
* with an empty models block can never be emitted.
107+
*
108+
* Resolved ONCE and handed back to `runtimeRequest` as `baseUrl`, so the catalog and the
109+
* exported endpoint can never come from two different probes.
110+
*/
111+
function proxyV1BaseUrl(root: string): string {
112+
const url = new URL(root);
113+
const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
114+
return opencodeProxyBaseUrl(port, url.hostname);
115+
}
116+
117+
function parseClient(args: string[]): ExportClientId {
118+
const raw = takeOption(args, "--client");
119+
if (raw === undefined) {
120+
throw new CliUsageError(`--client is required (${EXPORT_CLIENT_IDS.join(", ")})`, USAGE);
121+
}
122+
const client = raw.trim().toLowerCase();
123+
if (!isExportClientId(client)) {
124+
throw new CliUsageError(`--client must be one of: ${EXPORT_CLIENT_IDS.join(", ")}`, USAGE);
125+
}
126+
return client;
127+
}
128+
129+
/**
130+
* Write the config, refusing to replace an existing file without `--force`.
131+
*
132+
* The `wx` flag does the refusal in the kernel rather than after an `existsSync` check, so
133+
* a file created between the two can never be truncated. Nothing is printed before this
134+
* runs: a refusal leaves both the target bytes and stdout untouched.
135+
*/
136+
function writeExport(path: string, text: string, force: boolean): void {
137+
try {
138+
writeFileSync(path, text, force ? { encoding: "utf8" } : { encoding: "utf8", flag: "wx" });
139+
} catch (error) {
140+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
141+
throw new CliUsageError(
142+
`${path} already exists. Re-run with --force to replace it, or print the config and merge it yourself.`,
143+
USAGE,
144+
);
145+
}
146+
throw error;
147+
}
148+
}
149+
150+
export async function handleExportCommand(argv: string[], deps: ExportCommandDeps = {}): Promise<number> {
151+
return runCliAction(async () => {
152+
const args = [...argv];
153+
const client = parseClient(args);
154+
const wantsJson = takeFlag(args, "--json");
155+
const force = takeFlag(args, "--force");
156+
const out = takeOption(args, "--out");
157+
rejectArgs(args, USAGE);
158+
159+
const spec = EXPORT_CLIENTS[client];
160+
const config = (deps.configImpl ?? loadConfig)();
161+
const root = await runtimeBaseUrl(deps);
162+
const rows = await runtimeRequest<ExportProxyModelRow[]>("/api/models", {}, { ...deps, baseUrl: root });
163+
if (!Array.isArray(rows)) {
164+
throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows);
165+
}
166+
const models = exportModelsFromProxyRows(rows, config);
167+
const clientConfig = buildClientConfig(client, { baseUrl: proxyV1BaseUrl(root), models, config });
168+
const text = JSON.stringify(clientConfig, null, 2);
169+
170+
if (out !== undefined) writeExport(out, `${text}\n`, force);
171+
// stderr, so `--json` stdout stays byte-exact for a redirect.
172+
if (out !== undefined && wantsJson) console.error(`Wrote ${out}`);
173+
174+
const degraded = models.filter(model => !hasContextLimit(model)).length;
175+
printData(clientConfig, wantsJson, [
176+
text,
177+
"",
178+
...(out !== undefined ? [`Wrote ${out}`] : []),
179+
`Destination: ${spec.destination(process.env)}`,
180+
"Merge this provider block into that file; do not replace it.",
181+
`Before launching: ${spec.exportHint}`,
182+
`${models.length} model${models.length === 1 ? "" : "s"}; ${degraded} omit context limits (the client applies its own defaults).`,
183+
]);
184+
});
185+
}
186+
187+
export const EXPORT_USAGE = USAGE;

src/cli/help.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,16 @@ const helpEntries: Record<string, HelpEntry> = {
169169
summary: "Manage OpenCodex admission API keys and inspect external endpoints.",
170170
},
171171
"api-key": { usage: "ocx api-key <list|create|remove> ...", summary: "Alias of ocx access key." },
172+
export: {
173+
usage: "ocx export --client <opencode|pi> [--json] [--out <path>] [--force]",
174+
summary: "Print a client config (opencode, Pi) wired to the running proxy.",
175+
details: [
176+
"--json prints only the config JSON on stdout, so it is safe to redirect to a file.",
177+
"--out <path> writes the config there and refuses to replace an existing file without --force.",
178+
"The config never contains a key; it references the client's env var, which you export before launching.",
179+
"The destination path is printed for merging by hand — ocx never writes your real client config.",
180+
],
181+
},
172182
grok: { usage: "ocx grok <status|exclude|include|set|clear|apply> ...", summary: "Manage and apply the Grok Build model fence." },
173183
integration: { usage: "ocx integration <claude|grok> ...", summary: "Manage supported client integrations." },
174184
system: {
@@ -286,6 +296,7 @@ Usage:
286296
ocx agent <sub> Subagents, injection, effort caps, and sidecars
287297
ocx observe <sub> Logs, usage, storage, memory, and debug data
288298
ocx access <sub> External API keys and endpoint information
299+
ocx export --client <id> Print an opencode/Pi config wired to the running proxy
289300
ocx grok <sub> Grok Build model selection and apply
290301
ocx system <sub> Runtime settings, startup, sync, and updates
291302
ocx config <sub> Validated configuration show/get/set/import/export

src/cli/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,11 @@ switch (command) {
10311031
process.exitCode = await handleAccessCommand(["key", ...args.slice(1)]);
10321032
break;
10331033
}
1034+
case "export": {
1035+
const { handleExportCommand } = await import("./export-command");
1036+
process.exitCode = await handleExportCommand(args.slice(1));
1037+
break;
1038+
}
10341039
case "grok": {
10351040
const { handleGrokCommand } = await import("./integrations");
10361041
process.exitCode = await handleGrokCommand(args.slice(1));

0 commit comments

Comments
 (0)