@koreanhole/opencodex 2.95.10 → 2.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +33 -6
- package/README.md +100 -31
- package/package.json +6 -2
- package/src/adapters/cli-agent/acp-client.ts +545 -0
- package/src/adapters/cli-agent/cline/acp.ts +15 -279
- package/src/adapters/cli-agent/cline/adapter.ts +3 -3
- package/src/adapters/cli-agent/cline/live-models.ts +6 -5
- package/src/adapters/cli-agent/cline/stream.ts +6 -37
- package/src/adapters/cli-agent/gemini/acp.ts +656 -0
- package/src/adapters/cli-agent/gemini/adapter.ts +253 -0
- package/src/adapters/cli-agent/gemini/binary.ts +23 -0
- package/src/adapters/cli-agent/gemini/live-models.ts +154 -0
- package/src/adapters/cli-agent/gemini/model-metadata.ts +291 -0
- package/src/adapters/cli-agent/mcp-server.ts +29 -4
- package/src/adapters/cli-agent/tool-intent.ts +71 -0
- package/src/cli/index.ts +81 -19
- package/src/config.ts +50 -9
- package/src/index.ts +1 -0
- package/src/lib/codex-catalog.ts +318 -0
- package/src/lib/redact.ts +4 -1
- package/src/server/index.ts +46 -13
- package/src/server/messages.ts +9 -8
- package/src/server/responses.ts +126 -12
- package/src/types.ts +14 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import type { OcxProviderModel, OcxReasoningLevel } from "../../../types";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_METADATA_TTL_MS = 5 * 60_000;
|
|
7
|
+
|
|
8
|
+
export interface GeminiThinkingConfig {
|
|
9
|
+
thinkingLevel?: string;
|
|
10
|
+
thinkingBudget?: number;
|
|
11
|
+
includeThoughts?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GeminiReasoningLevel extends OcxReasoningLevel {
|
|
15
|
+
thinkingConfig: GeminiThinkingConfig;
|
|
16
|
+
originalEffort: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GeminiModelCapability {
|
|
20
|
+
thinkingSupported: boolean;
|
|
21
|
+
reasoningLevels: GeminiReasoningLevel[];
|
|
22
|
+
defaultReasoningLevel?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GeminiMetadataResult {
|
|
26
|
+
packageRoot?: string;
|
|
27
|
+
settingsPath?: string;
|
|
28
|
+
capabilities: Record<string, GeminiModelCapability>;
|
|
29
|
+
warning?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GeminiMetadataOptions {
|
|
33
|
+
binary: string;
|
|
34
|
+
env?: NodeJS.ProcessEnv;
|
|
35
|
+
packageRoot?: string;
|
|
36
|
+
ttlMs?: number;
|
|
37
|
+
now?: () => number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface CachedMetadata extends GeminiMetadataResult {
|
|
41
|
+
fetchedAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const metadataCache = new Map<string, CachedMetadata>();
|
|
45
|
+
|
|
46
|
+
export function resetGeminiMetadataCacheForTests(): void {
|
|
47
|
+
metadataCache.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
51
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
52
|
+
? value as Record<string, unknown>
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stringValue(value: unknown): string | undefined {
|
|
57
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractDefaultJsonValue(markdown: string, property: string): unknown {
|
|
61
|
+
const marker = `- **\`modelConfigs.${property}\`**`;
|
|
62
|
+
const start = markdown.indexOf(marker);
|
|
63
|
+
if (start < 0) return undefined;
|
|
64
|
+
const fenceStart = markdown.indexOf("```json", start);
|
|
65
|
+
if (fenceStart < 0) return undefined;
|
|
66
|
+
const bodyStart = markdown.indexOf("\n", fenceStart) + 1;
|
|
67
|
+
const fenceEnd = markdown.indexOf("```", bodyStart);
|
|
68
|
+
if (bodyStart <= 0 || fenceEnd < 0) return undefined;
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(markdown.slice(bodyStart, fenceEnd).trim()) as unknown;
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function extractDefaultJson(markdown: string, property: string): Record<string, unknown> | undefined {
|
|
77
|
+
return asRecord(extractDefaultJsonValue(markdown, property));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function findPackageRoot(binary: string): Promise<string | undefined> {
|
|
81
|
+
let current: string;
|
|
82
|
+
try {
|
|
83
|
+
current = dirname(await realpath(binary));
|
|
84
|
+
} catch {
|
|
85
|
+
current = dirname(resolve(binary));
|
|
86
|
+
}
|
|
87
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
88
|
+
try {
|
|
89
|
+
const manifest = JSON.parse(await readFile(join(current, "package.json"), "utf8")) as { name?: unknown };
|
|
90
|
+
if (manifest.name === "@google/gemini-cli") return current;
|
|
91
|
+
} catch {
|
|
92
|
+
// Keep walking toward the package root.
|
|
93
|
+
}
|
|
94
|
+
const parent = dirname(current);
|
|
95
|
+
if (parent === current) break;
|
|
96
|
+
current = parent;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readThinkingLevels(packageRoot: string): Promise<Array<{ effort: string; original: string }>> {
|
|
102
|
+
let files: string[];
|
|
103
|
+
try {
|
|
104
|
+
files = (await readdir(join(packageRoot, "bundle"))).filter(name => name.endsWith(".js"));
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const candidates = await Promise.all(files.map(async name => {
|
|
109
|
+
const path = join(packageRoot, "bundle", name);
|
|
110
|
+
const info = await stat(path).catch(() => undefined);
|
|
111
|
+
return info ? { path, size: info.size } : undefined;
|
|
112
|
+
}));
|
|
113
|
+
candidates.sort((left, right) => (left?.size ?? Infinity) - (right?.size ?? Infinity));
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
if (!candidate) continue;
|
|
116
|
+
const text = await readFile(candidate.path, "utf8").catch(() => "");
|
|
117
|
+
if (!text.includes("ThinkingLevel2[\"LOW\"]")) continue;
|
|
118
|
+
const levels = [...text.matchAll(/ThinkingLevel2\["([A-Z0-9_]+)"\]/g)]
|
|
119
|
+
.map(match => match[1]!)
|
|
120
|
+
.filter(level => level !== "THINKING_LEVEL_UNSPECIFIED");
|
|
121
|
+
return [...new Set(levels)].map(level => ({ effort: level.toLowerCase(), original: level }));
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function mergeConfig(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
|
127
|
+
const out = { ...target };
|
|
128
|
+
for (const [key, value] of Object.entries(source)) {
|
|
129
|
+
const targetRecord = asRecord(out[key]);
|
|
130
|
+
const sourceRecord = asRecord(value);
|
|
131
|
+
out[key] = targetRecord && sourceRecord ? mergeConfig(targetRecord, sourceRecord) : value;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveAlias(
|
|
137
|
+
id: string,
|
|
138
|
+
aliases: Record<string, unknown>,
|
|
139
|
+
seen = new Set<string>(),
|
|
140
|
+
): Record<string, unknown> {
|
|
141
|
+
if (seen.has(id)) return {};
|
|
142
|
+
seen.add(id);
|
|
143
|
+
const alias = asRecord(aliases[id]);
|
|
144
|
+
if (!alias) return {};
|
|
145
|
+
const parent = stringValue(alias.extends);
|
|
146
|
+
const inherited = parent ? resolveAlias(parent, aliases, seen) : {};
|
|
147
|
+
return mergeConfig(inherited, asRecord(alias.modelConfig) ?? {});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function capabilitiesFromMetadata(
|
|
151
|
+
definitions: Record<string, unknown>,
|
|
152
|
+
aliases: Record<string, unknown>,
|
|
153
|
+
levels: Array<{ effort: string; original: string }>,
|
|
154
|
+
overrides: readonly unknown[],
|
|
155
|
+
): Record<string, GeminiModelCapability> {
|
|
156
|
+
const capabilities: Record<string, GeminiModelCapability> = {};
|
|
157
|
+
for (const [modelId, rawDefinition] of Object.entries(definitions)) {
|
|
158
|
+
const definition = asRecord(rawDefinition);
|
|
159
|
+
const features = asRecord(definition?.features);
|
|
160
|
+
const thinkingSupported = features?.thinking === true;
|
|
161
|
+
if (!thinkingSupported) {
|
|
162
|
+
capabilities[modelId] = { thinkingSupported: false, reasoningLevels: [] };
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
let generateContentConfig = asRecord(resolveAlias(modelId, aliases).generateContentConfig) ?? {};
|
|
166
|
+
for (const rawOverride of overrides) {
|
|
167
|
+
const override = asRecord(rawOverride);
|
|
168
|
+
if (stringValue(asRecord(override?.match)?.model) !== modelId) continue;
|
|
169
|
+
generateContentConfig = mergeConfig(generateContentConfig, asRecord(asRecord(override?.modelConfig)?.generateContentConfig) ?? {});
|
|
170
|
+
}
|
|
171
|
+
const config = asRecord(generateContentConfig.thinkingConfig);
|
|
172
|
+
const configuredLevel = stringValue(config?.thinkingLevel)?.toLowerCase();
|
|
173
|
+
const includeThoughts = config?.includeThoughts === true;
|
|
174
|
+
const reasoningLevels = levels.map(level => ({
|
|
175
|
+
effort: level.effort,
|
|
176
|
+
originalEffort: level.original,
|
|
177
|
+
description: `Gemini CLI ${level.original} thinking level`,
|
|
178
|
+
thinkingConfig: {
|
|
179
|
+
thinkingLevel: level.original,
|
|
180
|
+
...(includeThoughts ? { includeThoughts: true } : {}),
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
const defaultReasoningLevel = reasoningLevels.some(level => level.effort === configuredLevel)
|
|
184
|
+
? configuredLevel
|
|
185
|
+
: reasoningLevels.find(level => level.effort === "high")?.effort ?? reasoningLevels[0]?.effort;
|
|
186
|
+
capabilities[modelId] = {
|
|
187
|
+
thinkingSupported: true,
|
|
188
|
+
reasoningLevels,
|
|
189
|
+
...(defaultReasoningLevel ? { defaultReasoningLevel } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return capabilities;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function loadMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
|
|
196
|
+
const env = options.env ?? process.env;
|
|
197
|
+
const packageRoot = options.packageRoot ?? await findPackageRoot(options.binary);
|
|
198
|
+
if (!packageRoot) return { capabilities: {}, warning: "Gemini CLI package metadata was not found." };
|
|
199
|
+
const docsPath = join(packageRoot, "bundle", "docs", "reference", "configuration.md");
|
|
200
|
+
let markdown: string;
|
|
201
|
+
try {
|
|
202
|
+
markdown = await readFile(docsPath, "utf8");
|
|
203
|
+
} catch {
|
|
204
|
+
return { packageRoot, capabilities: {}, warning: "Gemini CLI model configuration documentation was not found." };
|
|
205
|
+
}
|
|
206
|
+
const defaultAliases = extractDefaultJson(markdown, "aliases");
|
|
207
|
+
const defaultDefinitions = extractDefaultJson(markdown, "modelDefinitions");
|
|
208
|
+
const defaultOverrides = extractDefaultJsonValue(markdown, "overrides");
|
|
209
|
+
if (!defaultAliases || !defaultDefinitions) {
|
|
210
|
+
return { packageRoot, capabilities: {}, warning: "Gemini CLI model configuration metadata could not be parsed." };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const home = stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir();
|
|
214
|
+
const settingsPath = join(home, ".gemini", "settings.json");
|
|
215
|
+
let userSettings: Record<string, unknown> = {};
|
|
216
|
+
try {
|
|
217
|
+
userSettings = asRecord(JSON.parse(await readFile(settingsPath, "utf8"))) ?? {};
|
|
218
|
+
} catch {
|
|
219
|
+
// User settings are optional; absence is not a metadata failure.
|
|
220
|
+
}
|
|
221
|
+
const modelConfigs = asRecord(userSettings.modelConfigs) ?? {};
|
|
222
|
+
const aliases = mergeConfig(
|
|
223
|
+
mergeConfig(defaultAliases, asRecord(modelConfigs.customAliases) ?? {}),
|
|
224
|
+
asRecord(modelConfigs.aliases) ?? {},
|
|
225
|
+
);
|
|
226
|
+
const definitions = mergeConfig(defaultDefinitions, asRecord(modelConfigs.modelDefinitions) ?? {});
|
|
227
|
+
const overrides = [
|
|
228
|
+
...(Array.isArray(defaultOverrides) ? defaultOverrides : []),
|
|
229
|
+
...(Array.isArray(modelConfigs.overrides) ? modelConfigs.overrides : []),
|
|
230
|
+
...(Array.isArray(modelConfigs.customOverrides) ? modelConfigs.customOverrides : []),
|
|
231
|
+
];
|
|
232
|
+
const levels = await readThinkingLevels(packageRoot);
|
|
233
|
+
if (levels.length === 0) {
|
|
234
|
+
return { packageRoot, settingsPath, capabilities: {}, warning: "Gemini CLI thinking-level metadata was not found." };
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
packageRoot,
|
|
238
|
+
settingsPath,
|
|
239
|
+
capabilities: capabilitiesFromMetadata(definitions, aliases, levels, overrides),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function discoverGeminiModelMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
|
|
244
|
+
const env = options.env ?? process.env;
|
|
245
|
+
const key = [
|
|
246
|
+
options.packageRoot ?? "auto",
|
|
247
|
+
resolve(options.binary),
|
|
248
|
+
stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir(),
|
|
249
|
+
].join("\n");
|
|
250
|
+
const now = options.now ?? Date.now;
|
|
251
|
+
const ttlMs = options.ttlMs ?? DEFAULT_METADATA_TTL_MS;
|
|
252
|
+
const cached = metadataCache.get(key);
|
|
253
|
+
if (cached && now() - cached.fetchedAt < ttlMs) return cached;
|
|
254
|
+
const result = await loadMetadata(options);
|
|
255
|
+
const entry: CachedMetadata = { ...result, fetchedAt: now() };
|
|
256
|
+
metadataCache.set(key, entry);
|
|
257
|
+
return entry;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function mergeGeminiModelDescriptors(
|
|
261
|
+
advertised: readonly { modelId: string; name?: string; description?: string }[],
|
|
262
|
+
metadata: GeminiMetadataResult,
|
|
263
|
+
): OcxProviderModel[] {
|
|
264
|
+
return advertised.map(model => {
|
|
265
|
+
const capability = metadata.capabilities[model.modelId];
|
|
266
|
+
const reasoningLevels = capability?.reasoningLevels.map(level => ({
|
|
267
|
+
effort: level.effort,
|
|
268
|
+
description: level.description,
|
|
269
|
+
})) ?? [];
|
|
270
|
+
return {
|
|
271
|
+
id: model.modelId,
|
|
272
|
+
...(model.name ? { name: model.name } : {}),
|
|
273
|
+
...(model.description ? { description: model.description } : {}),
|
|
274
|
+
supportedReasoningLevels: reasoningLevels,
|
|
275
|
+
...(capability?.defaultReasoningLevel ? { defaultReasoningLevel: capability.defaultReasoningLevel } : {}),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function reasoningLevelForEffort(
|
|
281
|
+
capability: GeminiModelCapability | undefined,
|
|
282
|
+
effort: string | undefined,
|
|
283
|
+
): GeminiReasoningLevel | undefined {
|
|
284
|
+
if (!capability?.thinkingSupported || capability.reasoningLevels.length === 0) return undefined;
|
|
285
|
+
const wanted = effort?.trim().toLowerCase();
|
|
286
|
+
if (!wanted) {
|
|
287
|
+
return capability.reasoningLevels.find(level => level.effort === capability.defaultReasoningLevel)
|
|
288
|
+
?? capability.reasoningLevels[0];
|
|
289
|
+
}
|
|
290
|
+
return capability.reasoningLevels.find(level => level.effort === wanted);
|
|
291
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { appendFile, readFile } from "node:fs/promises";
|
|
2
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -9,21 +9,46 @@ export interface CodingAgentMcpTool {
|
|
|
9
9
|
inputSchema: Record<string, unknown>;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
const MAX_CAPTURE_CALLS = 16;
|
|
13
|
+
const MAX_CAPTURE_ARGUMENT_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export async function serveCodingAgentMcpTools(
|
|
16
|
+
tools: CodingAgentMcpTool[],
|
|
17
|
+
serverName: string,
|
|
18
|
+
capturePath?: string,
|
|
19
|
+
): Promise<void> {
|
|
13
20
|
const exitOnStdinClose = (): void => process.exit(0);
|
|
14
21
|
process.stdin.on("end", exitOnStdinClose);
|
|
15
22
|
process.stdin.on("close", exitOnStdinClose);
|
|
16
23
|
const names = new Set(tools.map(tool => tool.name));
|
|
17
24
|
const server = new Server({ name: serverName, version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
25
|
+
let capturedCalls = 0;
|
|
18
26
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
19
27
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
20
28
|
if (!names.has(request.params.name)) throw new Error("unknown isolated tool");
|
|
29
|
+
if (capturePath) {
|
|
30
|
+
if (++capturedCalls > MAX_CAPTURE_CALLS) throw new Error("isolated tool capture limit exceeded");
|
|
31
|
+
const argumentsValue = request.params.arguments ?? {};
|
|
32
|
+
const serialized = JSON.stringify(argumentsValue);
|
|
33
|
+
if (Buffer.byteLength(serialized) > MAX_CAPTURE_ARGUMENT_BYTES) {
|
|
34
|
+
throw new Error("isolated tool arguments exceed the capture limit");
|
|
35
|
+
}
|
|
36
|
+
await appendFile(capturePath, `${JSON.stringify({
|
|
37
|
+
id: `gemini-tool-${crypto.randomUUID()}`,
|
|
38
|
+
name: request.params.name,
|
|
39
|
+
arguments: argumentsValue,
|
|
40
|
+
})}\n`, { encoding: "utf8", mode: 0o600 });
|
|
41
|
+
}
|
|
21
42
|
return new Promise<never>(() => undefined);
|
|
22
43
|
});
|
|
23
44
|
await server.connect(new StdioServerTransport());
|
|
24
45
|
}
|
|
25
46
|
|
|
26
|
-
export async function runCodingAgentMcpServer(catalogPath: string): Promise<void> {
|
|
47
|
+
export async function runCodingAgentMcpServer(catalogPath: string, capturePath?: string): Promise<void> {
|
|
27
48
|
const tools = JSON.parse(await readFile(catalogPath, "utf8")) as CodingAgentMcpTool[];
|
|
28
|
-
await serveCodingAgentMcpTools(tools, "opencodex-coding-agent-tools");
|
|
49
|
+
await serveCodingAgentMcpTools(tools, "opencodex-coding-agent-tools", capturePath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (import.meta.main) {
|
|
53
|
+
await runCodingAgentMcpServer(process.argv[2] ?? "", process.argv[3]);
|
|
29
54
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import type { AdapterEvent } from "../../types";
|
|
3
|
+
import type { CliAgentStreamState } from "./types";
|
|
4
|
+
|
|
5
|
+
const MAX_TOOL_ARGUMENT_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export class ToolIntentError extends Error {
|
|
8
|
+
readonly code = "cli_protocol_error";
|
|
9
|
+
readonly status = 502;
|
|
10
|
+
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ToolIntentError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ToolIntentOptions {
|
|
18
|
+
toolNames: ReadonlyMap<string, string>;
|
|
19
|
+
maxToolCalls?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
23
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
24
|
+
? value as Record<string, unknown>
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toolArguments(input: unknown, label: string): string {
|
|
29
|
+
let value = input;
|
|
30
|
+
if (typeof value === "string") {
|
|
31
|
+
try {
|
|
32
|
+
value = JSON.parse(value);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new ToolIntentError(`${label} returned a tool call with invalid JSON arguments.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const record = asRecord(value ?? {});
|
|
38
|
+
if (!record) throw new ToolIntentError(`${label} returned non-object tool arguments.`);
|
|
39
|
+
const serialized = JSON.stringify(record);
|
|
40
|
+
if (Buffer.byteLength(serialized) > MAX_TOOL_ARGUMENT_BYTES) {
|
|
41
|
+
throw new ToolIntentError(`${label} returned tool arguments above the byte limit.`);
|
|
42
|
+
}
|
|
43
|
+
return serialized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Convert one validated vendor tool request into events owned by Codex. */
|
|
47
|
+
export function mapToolIntent(
|
|
48
|
+
label: string,
|
|
49
|
+
vendorName: string,
|
|
50
|
+
toolCallId: string | undefined,
|
|
51
|
+
input: unknown,
|
|
52
|
+
state: CliAgentStreamState,
|
|
53
|
+
options: ToolIntentOptions,
|
|
54
|
+
): AdapterEvent[] {
|
|
55
|
+
if (!vendorName) throw new ToolIntentError(`${label} returned a tool call without a name.`);
|
|
56
|
+
const wireName = options.toolNames.get(vendorName);
|
|
57
|
+
if (!wireName) throw new ToolIntentError(`${label} returned an undeclared tool: ${vendorName.slice(0, 128)}`);
|
|
58
|
+
const maxToolCalls = options.maxToolCalls ?? 16;
|
|
59
|
+
if (state.toolCalls >= maxToolCalls) {
|
|
60
|
+
throw new ToolIntentError(`${label} exceeded the per-turn tool-call limit.`);
|
|
61
|
+
}
|
|
62
|
+
const ordinal = ++state.nextToolOrdinal;
|
|
63
|
+
const id = toolCallId?.trim() || `${label.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-tool-${ordinal}`;
|
|
64
|
+
const argumentsJson = toolArguments(input, label);
|
|
65
|
+
state.toolCalls++;
|
|
66
|
+
return [
|
|
67
|
+
{ type: "tool_call_start", id, name: wireName },
|
|
68
|
+
{ type: "tool_call_delta", arguments: argumentsJson },
|
|
69
|
+
{ type: "tool_call_end" },
|
|
70
|
+
];
|
|
71
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import {
|
|
6
6
|
CLINE_PROVIDER_ID,
|
|
7
|
+
GEMINI_PROVIDER_ID,
|
|
7
8
|
ConfigError,
|
|
8
9
|
apiToken,
|
|
9
10
|
defaultConfig,
|
|
@@ -13,10 +14,12 @@ import {
|
|
|
13
14
|
removeRuntimeStateIfPid,
|
|
14
15
|
saveConfig,
|
|
15
16
|
writeRuntimeState,
|
|
17
|
+
type ProviderId,
|
|
16
18
|
} from "../config";
|
|
17
19
|
import { startServer } from "../server";
|
|
18
20
|
import { loginClineAcp } from "../adapters/cli-agent/cline/acp";
|
|
19
21
|
import { runCodingAgentMcpServer } from "../adapters/cli-agent/mcp-server";
|
|
22
|
+
import { syncCodexCatalog } from "../lib/codex-catalog";
|
|
20
23
|
|
|
21
24
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
25
|
|
|
@@ -47,20 +50,24 @@ async function version(): Promise<string> {
|
|
|
47
50
|
|
|
48
51
|
function help(): string {
|
|
49
52
|
return [
|
|
50
|
-
"opencodex
|
|
53
|
+
"opencodex local CLI proxy",
|
|
51
54
|
"",
|
|
52
55
|
"Usage: ocx <command>",
|
|
53
56
|
"",
|
|
54
57
|
"Commands:",
|
|
55
58
|
" login cline-cli [--json] [--no-wait] Authenticate the local Cline CLI",
|
|
56
|
-
" provider add
|
|
57
|
-
" provider use
|
|
58
|
-
" provider list [--json] Show
|
|
59
|
+
" provider add <provider> [--set-default] Configure a CLI transport",
|
|
60
|
+
" provider use <provider> Select the default provider",
|
|
61
|
+
" provider list [--json] Show supported providers",
|
|
59
62
|
" start [--port <port>] [--host <host>] Start the proxy",
|
|
60
63
|
" status [--json] Check the running proxy",
|
|
61
64
|
" stop [--json] Stop the running proxy",
|
|
65
|
+
" codex sync [--json] Refresh the Codex model catalog",
|
|
62
66
|
" version Print the package version",
|
|
63
67
|
" help Show this help",
|
|
68
|
+
"",
|
|
69
|
+
"Providers: cline-cli, gemini-cli",
|
|
70
|
+
"Gemini authentication is managed by Gemini CLI (`gemini` or GEMINI_API_KEY).",
|
|
64
71
|
].join("\n");
|
|
65
72
|
}
|
|
66
73
|
|
|
@@ -88,7 +95,7 @@ async function health(host: string, port: number, config: Awaited<ReturnType<typ
|
|
|
88
95
|
});
|
|
89
96
|
if (!response.ok) return false;
|
|
90
97
|
const body = await response.json() as { service?: string; ok?: boolean };
|
|
91
|
-
return body.ok === true && body.service === "opencodex
|
|
98
|
+
return body.ok === true && body.service === "opencodex";
|
|
92
99
|
} catch {
|
|
93
100
|
return false;
|
|
94
101
|
}
|
|
@@ -100,31 +107,46 @@ async function providerCommand(args: string[]): Promise<number> {
|
|
|
100
107
|
if (sub === "list") {
|
|
101
108
|
const json = takeFlag(args, "--json");
|
|
102
109
|
if (args.length > 0) throw new ConfigError(`Unknown provider list option: ${args[0]}`);
|
|
103
|
-
if (json) console.log(JSON.stringify({ defaultProvider:
|
|
104
|
-
else
|
|
110
|
+
if (json) console.log(JSON.stringify({ defaultProvider: config.defaultProvider, providers: config.providers }, null, 2));
|
|
111
|
+
else {
|
|
112
|
+
for (const id of [CLINE_PROVIDER_ID, GEMINI_PROVIDER_ID]) {
|
|
113
|
+
console.log(`${id}${config.defaultProvider === id ? " (default)" : ""}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
105
116
|
return 0;
|
|
106
117
|
}
|
|
107
118
|
if (sub === "add") {
|
|
108
|
-
|
|
119
|
+
const requested = args.shift();
|
|
120
|
+
if (requested !== CLINE_PROVIDER_ID && requested !== GEMINI_PROVIDER_ID) {
|
|
121
|
+
throw new ConfigError("Only `provider add cline-cli` or `provider add gemini-cli` is supported.");
|
|
122
|
+
}
|
|
123
|
+
const id = requested as ProviderId;
|
|
109
124
|
const setDefault = takeFlag(args, "--set-default");
|
|
110
125
|
if (args.length > 0) throw new ConfigError(`Unknown provider add option: ${args[0]}`);
|
|
111
|
-
const next = { ...config, providers: { [
|
|
112
|
-
if (setDefault) next.defaultProvider =
|
|
126
|
+
const next = { ...config, providers: { ...config.providers, [id]: defaultConfig().providers[id] } };
|
|
127
|
+
if (setDefault) next.defaultProvider = id;
|
|
113
128
|
await saveConfig(next);
|
|
114
|
-
console.log(`Configured ${
|
|
129
|
+
console.log(`Configured ${id} at ${getConfigPath()}`);
|
|
115
130
|
return 0;
|
|
116
131
|
}
|
|
117
132
|
if (sub === "use") {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
const requested = args.shift();
|
|
134
|
+
if (requested !== CLINE_PROVIDER_ID && requested !== GEMINI_PROVIDER_ID) {
|
|
135
|
+
throw new ConfigError("Only `provider use cline-cli` or `provider use gemini-cli` is supported.");
|
|
136
|
+
}
|
|
137
|
+
await saveConfig({ ...config, defaultProvider: requested });
|
|
138
|
+
console.log(`Default provider: ${requested}`);
|
|
121
139
|
return 0;
|
|
122
140
|
}
|
|
123
141
|
throw new ConfigError(`Unknown provider command: ${sub ?? "(missing)"}`);
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
async function loginCommand(args: string[]): Promise<number> {
|
|
127
|
-
|
|
145
|
+
const provider = args.shift();
|
|
146
|
+
if (provider === GEMINI_PROVIDER_ID) {
|
|
147
|
+
throw new ConfigError("Gemini authentication is managed by Gemini CLI. Run `gemini` to log in or set GEMINI_API_KEY for the CLI.");
|
|
148
|
+
}
|
|
149
|
+
if (provider !== CLINE_PROVIDER_ID) throw new ConfigError("Only `login cline-cli` is supported; authenticate Gemini through Gemini CLI.");
|
|
128
150
|
const json = takeFlag(args, "--json");
|
|
129
151
|
const noWait = takeFlag(args, "--no-wait");
|
|
130
152
|
if (args.length > 0) throw new ConfigError(`Unknown login option: ${args[0]}`);
|
|
@@ -157,9 +179,23 @@ async function startCommand(args: string[]): Promise<number> {
|
|
|
157
179
|
config.port = parsed;
|
|
158
180
|
}
|
|
159
181
|
if (host !== undefined) config.host = host;
|
|
182
|
+
try {
|
|
183
|
+
const catalog = await syncCodexCatalog({ env });
|
|
184
|
+
if (catalog.ok && catalog.written) {
|
|
185
|
+
console.log(`Codex catalog refreshed: ${catalog.path}`);
|
|
186
|
+
console.log("Restart the Codex app/app-server to reload the model picker.");
|
|
187
|
+
if (!catalog.configured) {
|
|
188
|
+
console.log(`Activate it with: model_catalog_json = "${catalog.path}"`);
|
|
189
|
+
}
|
|
190
|
+
} else if (!catalog.ok) {
|
|
191
|
+
console.warn(`[opencodex] Codex catalog was not refreshed: ${catalog.message}`);
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.warn(`[opencodex] Codex catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
195
|
+
}
|
|
160
196
|
const running = startServer(config, { env });
|
|
161
197
|
await writeRuntimeState({ pid: process.pid, port: running.port, host: running.host, startedAt: new Date().toISOString() }, env);
|
|
162
|
-
console.log(`
|
|
198
|
+
console.log(`opencodex listening on http://${running.host}:${running.port}/v1 (default provider: ${config.defaultProvider})`);
|
|
163
199
|
console.log(`Health: http://${running.host}:${running.port}/healthz`);
|
|
164
200
|
|
|
165
201
|
const shutdown = async (signal: string): Promise<void> => {
|
|
@@ -174,6 +210,31 @@ async function startCommand(args: string[]): Promise<number> {
|
|
|
174
210
|
return 0;
|
|
175
211
|
}
|
|
176
212
|
|
|
213
|
+
async function codexCommand(args: string[]): Promise<number> {
|
|
214
|
+
const sub = args.shift();
|
|
215
|
+
if (sub !== "sync") throw new ConfigError(`Unknown codex command: ${sub ?? "(missing)"}. Expected sync.`);
|
|
216
|
+
const json = takeFlag(args, "--json");
|
|
217
|
+
if (args.length > 0) throw new ConfigError(`Unknown codex sync option: ${args[0]}`);
|
|
218
|
+
const result = await syncCodexCatalog({ env: process.env });
|
|
219
|
+
if (!result.ok) {
|
|
220
|
+
if (json) console.log(JSON.stringify({ ok: false, path: result.path, reason: result.reason, message: result.message }));
|
|
221
|
+
else console.error(`ocx: ${result.message}`);
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
if (json) {
|
|
225
|
+
console.log(JSON.stringify(result));
|
|
226
|
+
} else {
|
|
227
|
+
console.log(`Catalog: ${result.path}`);
|
|
228
|
+
console.log(`Models: ${result.modelCount} (Gemini ${result.geminiModelCount}, reasoning-enabled ${result.reasoningModelCount})`);
|
|
229
|
+
for (const warning of result.warnings) console.warn(`[opencodex] ${warning}`);
|
|
230
|
+
if (!result.configured) {
|
|
231
|
+
console.log(`Activate it with: model_catalog_json = "${result.path}"`);
|
|
232
|
+
}
|
|
233
|
+
console.log("Codex reload required: restart the Codex app or app-server model picker.");
|
|
234
|
+
}
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
177
238
|
async function statusCommand(args: string[]): Promise<number> {
|
|
178
239
|
const json = takeFlag(args, "--json");
|
|
179
240
|
if (args.length > 0) throw new ConfigError(`Unknown status option: ${args[0]}`);
|
|
@@ -208,7 +269,7 @@ async function stopCommand(args: string[]): Promise<number> {
|
|
|
208
269
|
return 0;
|
|
209
270
|
}
|
|
210
271
|
if (!await health(state.host, state.port, config, process.env)) {
|
|
211
|
-
throw new ConfigError(`Refusing to stop pid ${state.pid}: it is not the
|
|
272
|
+
throw new ConfigError(`Refusing to stop pid ${state.pid}: it is not the opencodex proxy recorded by this home.`);
|
|
212
273
|
}
|
|
213
274
|
process.kill(state.pid, "SIGTERM");
|
|
214
275
|
const deadline = Date.now() + 5_000;
|
|
@@ -222,7 +283,7 @@ async function stopCommand(args: string[]): Promise<number> {
|
|
|
222
283
|
|
|
223
284
|
async function main(argv: string[]): Promise<number> {
|
|
224
285
|
if (argv[0] === "__cli-agent-mcp") {
|
|
225
|
-
await runCodingAgentMcpServer(argv[1] ?? "");
|
|
286
|
+
await runCodingAgentMcpServer(argv[1] ?? "", argv[2]);
|
|
226
287
|
return 0;
|
|
227
288
|
}
|
|
228
289
|
const command = argv.shift();
|
|
@@ -236,10 +297,11 @@ async function main(argv: string[]): Promise<number> {
|
|
|
236
297
|
}
|
|
237
298
|
if (command === "login") return await loginCommand(argv);
|
|
238
299
|
if (command === "provider") return await providerCommand(argv);
|
|
300
|
+
if (command === "codex") return await codexCommand(argv);
|
|
239
301
|
if (command === "start") return await startCommand(argv);
|
|
240
302
|
if (command === "status") return await statusCommand(argv);
|
|
241
303
|
if (command === "stop") return await stopCommand(argv);
|
|
242
|
-
throw new ConfigError(`Unknown command: ${command}. Expected login, provider, start, status, stop, version, or help.`);
|
|
304
|
+
throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, version, or help.`);
|
|
243
305
|
}
|
|
244
306
|
|
|
245
307
|
try {
|