@koreanhole/opencodex 2.99.0 → 3.1.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 +15 -7
- package/README.md +91 -208
- package/bin/ocx.mjs +1 -1
- package/bin/package-main.mjs +11 -1
- package/package.json +8 -6
- package/src/adapter-runtime/config.ts +381 -0
- package/src/adapter-runtime/gateway.ts +121 -0
- package/src/adapter-runtime/scoped-server.ts +20 -0
- package/src/adapters/cli-agent/cline/adapter.ts +107 -90
- package/src/adapters/cli-agent/cline/json.ts +652 -0
- package/src/adapters/cli-agent/cline/settings.ts +64 -5
- package/src/adapters/cli-agent/cline/stream.ts +201 -1
- package/src/adapters/cli-agent/gemini/acp.ts +6 -1
- package/src/adapters/cli-agent/gemini/adapter.ts +1 -1
- package/src/adapters/cli-agent/gemini/live-models.ts +1 -1
- package/src/adapters/cli-agent/types.ts +1 -0
- package/src/cli/entry.ts +230 -0
- package/src/cli/index.ts +7 -5
- package/src/server/index.ts +28 -17
- package/src/server/responses.ts +42 -1
- package/src/upstream/cli.ts +12 -0
- package/src/upstream/package.ts +75 -0
|
@@ -6,8 +6,10 @@ import { baseScopedEnv } from "../env";
|
|
|
6
6
|
/** Built-in, subagent, and team tools that a model-only Cline turn must never enable. */
|
|
7
7
|
export const CLINE_DISABLED_TOOL_IDS = Object.freeze([
|
|
8
8
|
"read_files",
|
|
9
|
+
"list_files",
|
|
9
10
|
"search_codebase",
|
|
10
11
|
"run_commands",
|
|
12
|
+
"run_terminal_command",
|
|
11
13
|
"fetch_web_content",
|
|
12
14
|
"apply_patch",
|
|
13
15
|
"editor",
|
|
@@ -68,6 +70,10 @@ export interface ClineSelection {
|
|
|
68
70
|
model?: string;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
export type ClineMcpLaunch =
|
|
74
|
+
| { kind: "module"; modulePath: string }
|
|
75
|
+
| { kind: "entrypoint"; entrypoint: string };
|
|
76
|
+
|
|
71
77
|
interface ClineSelectionCandidate {
|
|
72
78
|
providerId: string;
|
|
73
79
|
model?: string;
|
|
@@ -261,12 +267,13 @@ export async function assertClineSelectionSafe(providerSettingsPath: string): Pr
|
|
|
261
267
|
* The vendor store is advisory: absence or another schema is Cline's business.
|
|
262
268
|
* Only an explicit route back through this proxy remains a hard refusal.
|
|
263
269
|
*/
|
|
264
|
-
export async function assertClineAdvisoryDestinationSafe(providerSettingsPath: string): Promise<
|
|
270
|
+
export async function assertClineAdvisoryDestinationSafe(providerSettingsPath: string): Promise<ClineSelection | undefined> {
|
|
265
271
|
try {
|
|
266
|
-
await assertClineSelectionSafe(providerSettingsPath);
|
|
272
|
+
return await assertClineSelectionSafe(providerSettingsPath);
|
|
267
273
|
} catch (error) {
|
|
268
274
|
if (error instanceof ClineSettingsError && error.code === "cli_provider_recursion") throw error;
|
|
269
275
|
if (!(error instanceof ClineSettingsError)) throw error;
|
|
276
|
+
return undefined;
|
|
270
277
|
}
|
|
271
278
|
}
|
|
272
279
|
|
|
@@ -285,8 +292,25 @@ function clineGlobalSettings(): Record<string, unknown> {
|
|
|
285
292
|
};
|
|
286
293
|
}
|
|
287
294
|
|
|
288
|
-
function
|
|
289
|
-
return
|
|
295
|
+
function clineMcpArgs(launch: ClineMcpLaunch, catalogPath: string): string[] {
|
|
296
|
+
return launch.kind === "module"
|
|
297
|
+
? [launch.modulePath, catalogPath]
|
|
298
|
+
: [launch.entrypoint, catalogPath];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function clineMcpSettings(launch: ClineMcpLaunch | undefined, catalogPath: string): Record<string, unknown> {
|
|
302
|
+
if (!launch) return { mcpServers: {} };
|
|
303
|
+
return {
|
|
304
|
+
mcpServers: {
|
|
305
|
+
opencodex: {
|
|
306
|
+
transport: {
|
|
307
|
+
type: "stdio",
|
|
308
|
+
command: process.execPath,
|
|
309
|
+
args: clineMcpArgs(launch, catalogPath),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
};
|
|
290
314
|
}
|
|
291
315
|
|
|
292
316
|
/** Share a staged file at a vendor-default path. */
|
|
@@ -346,6 +370,7 @@ async function linkClineSettings(input: {
|
|
|
346
370
|
export async function stageClineTurn(options: {
|
|
347
371
|
providerSettingsPath: string;
|
|
348
372
|
catalog: readonly Record<string, unknown>[];
|
|
373
|
+
mcpLaunch?: ClineMcpLaunch;
|
|
349
374
|
}): Promise<ClineStagedTurn> {
|
|
350
375
|
const root = await mkdtemp(join(tmpdir(), "ocx-cline-cli-"));
|
|
351
376
|
const configDir = join(root, "config");
|
|
@@ -378,7 +403,7 @@ export async function stageClineTurn(options: {
|
|
|
378
403
|
]);
|
|
379
404
|
await Promise.all([
|
|
380
405
|
writeFile(globalSettingsPath, `${JSON.stringify(clineGlobalSettings(), null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }),
|
|
381
|
-
writeFile(mcpSettingsPath, `${JSON.stringify(clineMcpSettings(), null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }),
|
|
406
|
+
writeFile(mcpSettingsPath, `${JSON.stringify(clineMcpSettings(options.mcpLaunch, catalogPath), null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }),
|
|
382
407
|
writeFile(catalogPath, JSON.stringify(options.catalog), { encoding: "utf8", mode: 0o600, flag: "wx" }),
|
|
383
408
|
]);
|
|
384
409
|
const copiedProviderStore = await copyClineProviderSettings(options.providerSettingsPath, providerSettingsPath);
|
|
@@ -409,6 +434,40 @@ export async function stageClineTurn(options: {
|
|
|
409
434
|
}
|
|
410
435
|
}
|
|
411
436
|
|
|
437
|
+
/** Merge newly discovered built-in IDs into the private staged disable list. */
|
|
438
|
+
export async function mergeClineDisabledTools(
|
|
439
|
+
staged: Pick<ClineStagedTurn, "globalSettingsPath">,
|
|
440
|
+
ids: readonly string[],
|
|
441
|
+
): Promise<void> {
|
|
442
|
+
let parsed: unknown;
|
|
443
|
+
try {
|
|
444
|
+
parsed = JSON.parse(await readFile(staged.globalSettingsPath, "utf8"));
|
|
445
|
+
} catch {
|
|
446
|
+
throw new ClineSettingsError(
|
|
447
|
+
"Cline global settings are malformed; the tool preflight cannot be repaired.",
|
|
448
|
+
500,
|
|
449
|
+
"cline_cli_setup_failed",
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
const settings = asRecord(parsed);
|
|
453
|
+
if (!settings) {
|
|
454
|
+
throw new ClineSettingsError(
|
|
455
|
+
"Cline global settings are malformed; the tool preflight cannot be repaired.",
|
|
456
|
+
500,
|
|
457
|
+
"cline_cli_setup_failed",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
const current = Array.isArray(settings.disabledTools)
|
|
461
|
+
? settings.disabledTools.filter((value): value is string => typeof value === "string")
|
|
462
|
+
: [];
|
|
463
|
+
const merged = [...new Set([...current, ...ids])];
|
|
464
|
+
await writeFile(
|
|
465
|
+
staged.globalSettingsPath,
|
|
466
|
+
`${JSON.stringify({ ...settings, disabledTools: merged }, null, 2)}\n`,
|
|
467
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
412
471
|
export function buildClineChildEnv(staged: ClineStagedTurn, sourceEnv: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
413
472
|
const env = baseScopedEnv();
|
|
414
473
|
env.HOME = staged.homeDir;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import type { AdapterEvent } from "../../../types";
|
|
1
|
+
import type { AdapterEvent, OcxUsage } from "../../../types";
|
|
2
2
|
import type { CliAgentStreamState } from "../types";
|
|
3
3
|
import { mapToolIntent, ToolIntentError } from "../tool-intent";
|
|
4
|
+
import { redactSecretString } from "../../../lib/redact";
|
|
5
|
+
|
|
6
|
+
const MAX_ERROR_CHARS = 2_048;
|
|
4
7
|
|
|
5
8
|
export class ClineStreamProtocolError extends Error {
|
|
6
9
|
readonly code = "cli_protocol_error";
|
|
@@ -42,3 +45,200 @@ export function mapClineToolIntent(
|
|
|
42
45
|
throw error;
|
|
43
46
|
}
|
|
44
47
|
}
|
|
48
|
+
|
|
49
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
50
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
51
|
+
? value as Record<string, unknown>
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function asString(value: unknown): string | undefined {
|
|
56
|
+
return typeof value === "string" ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeMessage(value: unknown): string {
|
|
60
|
+
const raw = typeof value === "string"
|
|
61
|
+
? value
|
|
62
|
+
: value instanceof Error
|
|
63
|
+
? value.message
|
|
64
|
+
: value === undefined
|
|
65
|
+
? "Cline reported an error."
|
|
66
|
+
: JSON.stringify(value) ?? String(value);
|
|
67
|
+
return redactSecretString(raw).slice(0, MAX_ERROR_CHARS);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function classifyError(message: string): { status: number; errorType: string; code: string; retryable: boolean } {
|
|
71
|
+
if (/not logged in|please (?:run )?\/?login|sign in|authentication (?:failed|required)|unauthorized|invalid (?:api )?key/i.test(message)) {
|
|
72
|
+
return { status: 401, errorType: "authentication_error", code: "cline_cli_not_logged_in", retryable: false };
|
|
73
|
+
}
|
|
74
|
+
if (/rate limit|too many requests|quota|credit|usage limit|daily limit/i.test(message)) {
|
|
75
|
+
return { status: 429, errorType: "rate_limit_error", code: "rate_limit_exceeded", retryable: true };
|
|
76
|
+
}
|
|
77
|
+
if (/model (?:is )?(?:not found|unavailable|unsupported)|invalid model/i.test(message)) {
|
|
78
|
+
return { status: 400, errorType: "invalid_request_error", code: "model_not_found", retryable: false };
|
|
79
|
+
}
|
|
80
|
+
return { status: 502, errorType: "upstream_error", code: "cli_protocol_error", retryable: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function usageFrom(value: unknown): OcxUsage | undefined {
|
|
84
|
+
const record = asRecord(value);
|
|
85
|
+
if (!record) return undefined;
|
|
86
|
+
const count = (input: unknown): number =>
|
|
87
|
+
typeof input === "number" && Number.isFinite(input) && input >= 0 ? Math.floor(input) : 0;
|
|
88
|
+
const inputTokens = count(record.inputTokens);
|
|
89
|
+
const outputTokens = count(record.outputTokens);
|
|
90
|
+
const cacheRead = count(record.cacheReadTokens);
|
|
91
|
+
const cacheWrite = count(record.cacheWriteTokens);
|
|
92
|
+
if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && cacheWrite === 0) return undefined;
|
|
93
|
+
return {
|
|
94
|
+
inputTokens,
|
|
95
|
+
outputTokens,
|
|
96
|
+
totalTokens: inputTokens + outputTokens,
|
|
97
|
+
...(cacheRead > 0 ? { cachedInputTokens: cacheRead, cacheReadInputTokens: cacheRead } : {}),
|
|
98
|
+
...(cacheWrite > 0 ? { cacheCreationInputTokens: cacheWrite } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function mapAgentEvent(
|
|
103
|
+
event: Record<string, unknown>,
|
|
104
|
+
state: CliAgentStreamState,
|
|
105
|
+
options: ClineStreamOptions,
|
|
106
|
+
): AdapterEvent[] {
|
|
107
|
+
const type = asString(event.type);
|
|
108
|
+
if (type === "content_start") {
|
|
109
|
+
const contentType = asString(event.contentType);
|
|
110
|
+
if (contentType === "text") {
|
|
111
|
+
const text = asString(event.text);
|
|
112
|
+
if (!text) return [];
|
|
113
|
+
state.sawText = true;
|
|
114
|
+
return [{ type: "text_delta", text }];
|
|
115
|
+
}
|
|
116
|
+
if (contentType === "reasoning") {
|
|
117
|
+
const thinking = asString(event.reasoning);
|
|
118
|
+
if (!thinking) return [];
|
|
119
|
+
state.sawThinking = true;
|
|
120
|
+
return [{ type: "thinking_delta", thinking }];
|
|
121
|
+
}
|
|
122
|
+
if (contentType === "tool") {
|
|
123
|
+
const vendorName = asString(event.toolName);
|
|
124
|
+
if (!vendorName) throw new ClineStreamProtocolError("Cline returned a tool call without a name.");
|
|
125
|
+
return mapClineToolIntent(vendorName, asString(event.toolCallId), event.input, state, options);
|
|
126
|
+
}
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (type === "content_end") {
|
|
131
|
+
const contentType = asString(event.contentType);
|
|
132
|
+
if (contentType === "text" && !state.sawText) {
|
|
133
|
+
const text = asString(event.text);
|
|
134
|
+
if (text) {
|
|
135
|
+
state.sawText = true;
|
|
136
|
+
return [{ type: "text_delta", text }];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (contentType === "reasoning" && !state.sawThinking) {
|
|
140
|
+
const thinking = asString(event.reasoning);
|
|
141
|
+
if (thinking) {
|
|
142
|
+
state.sawThinking = true;
|
|
143
|
+
return [{ type: "thinking_delta", thinking }];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (type === "usage") {
|
|
150
|
+
const usage = usageFrom(event);
|
|
151
|
+
if (usage) state.usage = usage;
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (type === "error") {
|
|
156
|
+
if (event.recoverable === true) return [];
|
|
157
|
+
const message = safeMessage(asRecord(event.error)?.message ?? event.error ?? "Cline reported an error.");
|
|
158
|
+
state.lastError = message;
|
|
159
|
+
state.terminal = true;
|
|
160
|
+
return [{ type: "error", message, ...classifyError(message) }];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (type === "done") {
|
|
164
|
+
state.terminal = true;
|
|
165
|
+
if (state.toolCalls > 0) return [];
|
|
166
|
+
const events: AdapterEvent[] = [];
|
|
167
|
+
if (!state.sawText) {
|
|
168
|
+
const text = asString(event.text);
|
|
169
|
+
if (text) {
|
|
170
|
+
state.sawText = true;
|
|
171
|
+
events.push({ type: "text_delta", text });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const usage = usageFrom(event.usage) ?? state.usage;
|
|
175
|
+
events.push({ type: "done", stopReason: "stop", ...(usage ? { usage } : {}) });
|
|
176
|
+
return events;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Map one bounded Cline JSON frame to provider-neutral events. */
|
|
183
|
+
export function mapClineMessage(
|
|
184
|
+
message: Record<string, unknown>,
|
|
185
|
+
state: CliAgentStreamState,
|
|
186
|
+
options: ClineStreamOptions,
|
|
187
|
+
): AdapterEvent[] {
|
|
188
|
+
if (state.terminal) return [];
|
|
189
|
+
const type = asString(message.type);
|
|
190
|
+
if (type === "agent_event") {
|
|
191
|
+
const event = asRecord(message.event);
|
|
192
|
+
return event ? mapAgentEvent(event, state, options) : [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (type === "run_result") {
|
|
196
|
+
if (state.toolCalls > 0) return [];
|
|
197
|
+
const usage = usageFrom(message.usage) ?? state.usage;
|
|
198
|
+
if (usage) state.usage = usage;
|
|
199
|
+
const finishReason = asString(message.finishReason);
|
|
200
|
+
if (finishReason && finishReason !== "completed" && finishReason !== "stop") {
|
|
201
|
+
const messageText = safeMessage(message.text ?? `Cline finished with ${finishReason}.`);
|
|
202
|
+
state.terminal = true;
|
|
203
|
+
return [{ type: "error", message: messageText, ...classifyError(messageText) }];
|
|
204
|
+
}
|
|
205
|
+
state.terminal = true;
|
|
206
|
+
const events: AdapterEvent[] = [];
|
|
207
|
+
if (!state.sawText) {
|
|
208
|
+
const text = asString(message.text);
|
|
209
|
+
if (text) {
|
|
210
|
+
state.sawText = true;
|
|
211
|
+
events.push({ type: "text_delta", text });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
events.push({ type: "done", stopReason: "stop", ...(usage ? { usage } : {}) });
|
|
215
|
+
return events;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (type === "run_aborted") {
|
|
219
|
+
state.terminal = true;
|
|
220
|
+
return [{
|
|
221
|
+
type: "error",
|
|
222
|
+
message: safeMessage(message.message ?? "Cline aborted the turn."),
|
|
223
|
+
status: 499,
|
|
224
|
+
errorType: "invalid_request_error",
|
|
225
|
+
code: "client_cancelled",
|
|
226
|
+
retryable: false,
|
|
227
|
+
}];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (type === "error") {
|
|
231
|
+
const detail = safeMessage(message.message ?? "Cline reported an error.");
|
|
232
|
+
state.lastError = detail;
|
|
233
|
+
state.terminal = true;
|
|
234
|
+
return [{ type: "error", message: detail, ...classifyError(detail) }];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Convert a nonzero process exit into a stable terminal error. */
|
|
241
|
+
export function classifyClineFailure(exitCode: number, stderr: string): Extract<AdapterEvent, { type: "error" }> {
|
|
242
|
+
const detail = safeMessage(stderr.trim() || `Cline exited with code ${exitCode}.`);
|
|
243
|
+
return { type: "error", message: detail, ...classifyError(detail) };
|
|
244
|
+
}
|
|
@@ -177,7 +177,10 @@ async function runGeminiHelp(binary: string, env: Record<string, string>, deps:
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
/** Build the Gemini ACP environment while leaving vendor auth in its real home. */
|
|
180
|
-
export function buildGeminiAcpEnv(
|
|
180
|
+
export function buildGeminiAcpEnv(
|
|
181
|
+
sourceEnv: NodeJS.ProcessEnv = process.env,
|
|
182
|
+
platform: NodeJS.Platform = process.platform,
|
|
183
|
+
): Record<string, string> {
|
|
181
184
|
const env = baseScopedEnv();
|
|
182
185
|
const realHome = nonEmpty(sourceEnv.HOME) ?? nonEmpty(sourceEnv.USERPROFILE) ?? homedir();
|
|
183
186
|
env.HOME = realHome;
|
|
@@ -192,6 +195,8 @@ export function buildGeminiAcpEnv(sourceEnv: NodeJS.ProcessEnv = process.env): R
|
|
|
192
195
|
NO_UPDATE_NOTIFIER: "1",
|
|
193
196
|
NO_COLOR: "1",
|
|
194
197
|
});
|
|
198
|
+
// Windows Node needs the OS store for corporate TLS roots.
|
|
199
|
+
if (platform === "win32") env.NODE_USE_SYSTEM_CA = "1";
|
|
195
200
|
return env;
|
|
196
201
|
}
|
|
197
202
|
|
|
@@ -287,7 +287,7 @@ export async function runGeminiTurn(
|
|
|
287
287
|
...(deps.reapTimeoutMs !== undefined ? { reapTimeoutMs: deps.reapTimeoutMs } : {}),
|
|
288
288
|
...(deps.killWindowsProcessTree ? { killWindowsProcessTree: deps.killWindowsProcessTree } : {}),
|
|
289
289
|
};
|
|
290
|
-
const acpEnv = buildGeminiAcpEnv(sourceEnv);
|
|
290
|
+
const acpEnv = buildGeminiAcpEnv(sourceEnv, deps.platform ?? process.platform);
|
|
291
291
|
let mode;
|
|
292
292
|
try {
|
|
293
293
|
mode = await (deps.probeAcp ?? probeGeminiAcpSupport)(binary, acpEnv, acpDeps);
|
|
@@ -105,7 +105,7 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
|
|
|
105
105
|
?? { ok: false, reason: "backoff", detail: "Gemini model discovery is cooling down after a failure.", fingerprint };
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
const env = buildGeminiAcpEnv(sourceEnv);
|
|
108
|
+
const env = buildGeminiAcpEnv(sourceEnv, options.deps?.platform ?? process.platform);
|
|
109
109
|
const mode = await probeGeminiAcpSupport(binary, env, { ...options.deps, env: sourceEnv });
|
|
110
110
|
if (!mode.acp) {
|
|
111
111
|
failureCache.set(fingerprint, timestamp);
|
package/src/cli/entry.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { rmSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
ADAPTER_DEFAULT_PORT,
|
|
6
|
+
ADAPTER_PROVIDER_IDS,
|
|
7
|
+
ADAPTER_TOKEN_ENV,
|
|
8
|
+
AdapterConfigError,
|
|
9
|
+
adapterRuntimePath,
|
|
10
|
+
discoverManagedProviderMetadata,
|
|
11
|
+
readAdapterRuntime,
|
|
12
|
+
readUpstreamConfig,
|
|
13
|
+
removeAdapterRuntimeIfPid,
|
|
14
|
+
restoreManagedProviderRows,
|
|
15
|
+
setAdapterDefaultProvider,
|
|
16
|
+
syncManagedProviderRows,
|
|
17
|
+
writeAdapterRuntime,
|
|
18
|
+
type AdapterProviderId,
|
|
19
|
+
} from "../adapter-runtime/config";
|
|
20
|
+
import { startAdapterGateway, type AdapterGateway } from "../adapter-runtime/gateway";
|
|
21
|
+
import { ConfigError } from "../config";
|
|
22
|
+
import { UpstreamPackageError, resolveUpstreamPackage } from "../upstream/package";
|
|
23
|
+
import { loadUpstreamCli } from "../upstream/cli";
|
|
24
|
+
import { runAdapterCli } from "./index";
|
|
25
|
+
import packageInfo from "../../package.json";
|
|
26
|
+
|
|
27
|
+
type AdapterCli = (argv: string[]) => Promise<number>;
|
|
28
|
+
type UpstreamLoader = () => Promise<void>;
|
|
29
|
+
type StartPreparer = (env: NodeJS.ProcessEnv) => Promise<unknown>;
|
|
30
|
+
|
|
31
|
+
export interface EntryDeps {
|
|
32
|
+
adapterCli?: AdapterCli;
|
|
33
|
+
loadUpstream?: UpstreamLoader;
|
|
34
|
+
prepareStart?: StartPreparer;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DISTRIBUTION_PACKAGE = "@koreanhole/opencodex";
|
|
38
|
+
|
|
39
|
+
function isAdapterId(value: string | undefined): value is AdapterProviderId {
|
|
40
|
+
return value === "gemini-cli" || value === "cline-cli";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isAdapterOwnedCommand(argv: readonly string[]): boolean {
|
|
44
|
+
const command = argv[0];
|
|
45
|
+
if (command === "__cli-agent-mcp") return true;
|
|
46
|
+
if (command === "version" || command === "--version" || command === "-v") return true;
|
|
47
|
+
if (command === "update") return true;
|
|
48
|
+
if (command === "system" && argv.slice(1).includes("update")) return true;
|
|
49
|
+
if (command === "login" && (argv[1] === "cline-cli" || argv[1] === "gemini-cli")) return true;
|
|
50
|
+
if (command === "adapter" && argv[1] === "detach") return true;
|
|
51
|
+
if (command !== "provider") return false;
|
|
52
|
+
if (argv[1] === "add" && isAdapterId(argv[2])) return true;
|
|
53
|
+
if (argv[1] === "use" && isAdapterId(argv[2])) return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function takeFlag(args: string[], flag: string): boolean {
|
|
58
|
+
const index = args.indexOf(flag);
|
|
59
|
+
if (index < 0) return false;
|
|
60
|
+
args.splice(index, 1);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function adapterProviderCommand(args: string[], env: NodeJS.ProcessEnv): Promise<number> {
|
|
65
|
+
const sub = args.shift();
|
|
66
|
+
if (sub !== "add" && sub !== "use") throw new ConfigError("Unknown provider command: " + String(sub ?? "(missing)"));
|
|
67
|
+
const provider = args.shift();
|
|
68
|
+
if (!isAdapterId(provider)) throw new ConfigError("Only gemini-cli and cline-cli are handled for provider " + sub + ".");
|
|
69
|
+
if (sub === "add") {
|
|
70
|
+
const setDefault = takeFlag(args, "--set-default");
|
|
71
|
+
if (args.length > 0) throw new ConfigError("Unknown provider add option: " + args[0]);
|
|
72
|
+
const runtime = await readAdapterRuntime(env);
|
|
73
|
+
await syncManagedProviderRows({
|
|
74
|
+
env,
|
|
75
|
+
gatewayPort: runtime?.port ?? ADAPTER_DEFAULT_PORT,
|
|
76
|
+
...(setDefault ? { defaultProvider: provider } : {}),
|
|
77
|
+
});
|
|
78
|
+
console.log("Configured " + provider + ".");
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
if (args.length > 0) throw new ConfigError("Unknown provider use option: " + args[0]);
|
|
82
|
+
await setAdapterDefaultProvider(provider, env);
|
|
83
|
+
console.log("Default provider: " + provider);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function detachAdapter(env: NodeJS.ProcessEnv): Promise<number> {
|
|
88
|
+
const result = await restoreManagedProviderRows(env);
|
|
89
|
+
if (!result.restored) {
|
|
90
|
+
console.log("CLI adapters are not attached.");
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
if (result.preservedUserRows.length > 0) {
|
|
94
|
+
console.log("Preserved user-owned provider rows: " + result.preservedUserRows.join(", "));
|
|
95
|
+
}
|
|
96
|
+
console.log("CLI adapter provider rows detached.");
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function printDistributionUpdate(argv: readonly string[]): number {
|
|
101
|
+
const command = "npm install -g " + DISTRIBUTION_PACKAGE + "@latest";
|
|
102
|
+
if (argv.includes("--json")) {
|
|
103
|
+
console.log(JSON.stringify({
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: "distribution_managed",
|
|
106
|
+
package: DISTRIBUTION_PACKAGE,
|
|
107
|
+
command,
|
|
108
|
+
}));
|
|
109
|
+
} else {
|
|
110
|
+
console.error("ocx: updates are managed by " + DISTRIBUTION_PACKAGE + ".");
|
|
111
|
+
console.error("Run: " + command);
|
|
112
|
+
}
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function printVersion(): number {
|
|
117
|
+
console.log("opencodex " + packageInfo.version);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function runAdapterCommand(argv: string[], env: NodeJS.ProcessEnv, adapterCli: AdapterCli): Promise<number> {
|
|
122
|
+
if (argv[0] === "__cli-agent-mcp") return adapterCli(argv);
|
|
123
|
+
if (argv[0] === "version" || argv[0] === "--version" || argv[0] === "-v") return printVersion();
|
|
124
|
+
if (argv[0] === "login") return adapterCli(argv);
|
|
125
|
+
if (argv[0] === "update" || (argv[0] === "system" && argv.slice(1).includes("update"))) {
|
|
126
|
+
return printDistributionUpdate(argv);
|
|
127
|
+
}
|
|
128
|
+
if (argv[0] === "adapter" && argv[1] === "detach") return detachAdapter(env);
|
|
129
|
+
if (argv[0] === "provider") return adapterProviderCommand(argv.slice(1), env);
|
|
130
|
+
throw new ConfigError("Internal adapter dispatch error.");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pidAlive(pid: number): boolean {
|
|
134
|
+
try {
|
|
135
|
+
process.kill(pid, 0);
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Attach the two managed rows and keep the gateway in the serving process. */
|
|
143
|
+
export async function prepareAdapterStart(
|
|
144
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
145
|
+
deps: {
|
|
146
|
+
discoverMetadata?: typeof discoverManagedProviderMetadata;
|
|
147
|
+
startGateway?: typeof startAdapterGateway;
|
|
148
|
+
syncRows?: typeof syncManagedProviderRows;
|
|
149
|
+
} = {},
|
|
150
|
+
): Promise<AdapterGateway | undefined> {
|
|
151
|
+
const existing = await readAdapterRuntime(env);
|
|
152
|
+
const inheritedToken = env[ADAPTER_TOKEN_ENV]?.trim();
|
|
153
|
+
if (existing && existing.pid !== process.pid && pidAlive(existing.pid) && !inheritedToken) {
|
|
154
|
+
throw new AdapterConfigError(
|
|
155
|
+
"The CLI adapter gateway is already running in another process; restart it from that process or stop it first.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const token = inheritedToken || randomBytes(32).toString("base64url");
|
|
159
|
+
env[ADAPTER_TOKEN_ENV] = token;
|
|
160
|
+
let gateway: AdapterGateway | undefined;
|
|
161
|
+
let gatewayPort: number;
|
|
162
|
+
if (existing && existing.pid !== process.pid && pidAlive(existing.pid)) {
|
|
163
|
+
gatewayPort = existing.port;
|
|
164
|
+
} else {
|
|
165
|
+
if (existing) await removeAdapterRuntimeIfPid(existing.pid, env);
|
|
166
|
+
gateway = (deps.startGateway ?? startAdapterGateway)({ token, env });
|
|
167
|
+
gatewayPort = gateway.port;
|
|
168
|
+
await writeAdapterRuntime({
|
|
169
|
+
version: 1,
|
|
170
|
+
pid: process.pid,
|
|
171
|
+
host: "127.0.0.1",
|
|
172
|
+
port: gatewayPort,
|
|
173
|
+
tokenEnv: ADAPTER_TOKEN_ENV,
|
|
174
|
+
providers: [...ADAPTER_PROVIDER_IDS],
|
|
175
|
+
startedAt: new Date().toISOString(),
|
|
176
|
+
}, env);
|
|
177
|
+
process.once("exit", () => {
|
|
178
|
+
try {
|
|
179
|
+
rmSync(adapterRuntimePath(env), { force: true });
|
|
180
|
+
} catch {
|
|
181
|
+
// best effort
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const metadata = await (deps.discoverMetadata ?? discoverManagedProviderMetadata)(env).catch(() => undefined);
|
|
188
|
+
const synced = await (deps.syncRows ?? syncManagedProviderRows)({
|
|
189
|
+
env,
|
|
190
|
+
gatewayPort,
|
|
191
|
+
...(metadata ? { metadata } : {}),
|
|
192
|
+
});
|
|
193
|
+
if (synced.providerEnvToken) env.OPENCODEX_API_AUTH_TOKEN ??= synced.providerEnvToken;
|
|
194
|
+
return gateway;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
await gateway?.stop().catch(() => undefined);
|
|
197
|
+
await removeAdapterRuntimeIfPid(process.pid, env).catch(() => undefined);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function runEntry(argv: string[], deps: EntryDeps = {}): Promise<void> {
|
|
203
|
+
const env = process.env;
|
|
204
|
+
env.OCX_DISABLE_UPDATE_CHECK ??= "1";
|
|
205
|
+
if (isAdapterOwnedCommand(argv)) {
|
|
206
|
+
process.exitCode = await runAdapterCommand(argv, env, deps.adapterCli ?? runAdapterCli);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (argv[0] === "start") {
|
|
210
|
+
await (deps.prepareStart ?? prepareAdapterStart)(env);
|
|
211
|
+
}
|
|
212
|
+
const entry = process.argv[1] ?? "ocx";
|
|
213
|
+
const executable = process.argv[0] ?? "bun";
|
|
214
|
+
process.argv = [executable, entry, ...argv];
|
|
215
|
+
await (deps.loadUpstream ?? (() => loadUpstreamCli()))();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function printError(error: unknown): number {
|
|
219
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
220
|
+
console.error("ocx: " + message);
|
|
221
|
+
return error instanceof ConfigError || error instanceof AdapterConfigError || error instanceof UpstreamPackageError ? 2 : 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (import.meta.main) {
|
|
225
|
+
try {
|
|
226
|
+
await runEntry(process.argv.slice(2));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
process.exitCode = printError(error);
|
|
229
|
+
}
|
|
230
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -348,7 +348,7 @@ async function stopCommand(args: string[]): Promise<number> {
|
|
|
348
348
|
return restored.success ? 0 : 1;
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
-
async function
|
|
351
|
+
export async function runAdapterCli(argv: string[]): Promise<number> {
|
|
352
352
|
if (argv[0] === "__cli-agent-mcp") {
|
|
353
353
|
await runCodingAgentMcpServer(argv[1] ?? "", argv[2]);
|
|
354
354
|
return 0;
|
|
@@ -372,8 +372,10 @@ async function main(argv: string[]): Promise<number> {
|
|
|
372
372
|
throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, restore, eject, version, or help.`);
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
375
|
+
if (import.meta.main) {
|
|
376
|
+
try {
|
|
377
|
+
process.exitCode = await runAdapterCli(process.argv.slice(2));
|
|
378
|
+
} catch (error) {
|
|
379
|
+
process.exitCode = printError(error);
|
|
380
|
+
}
|
|
379
381
|
}
|