llm-cli-gateway 1.1.0 → 1.4.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/CHANGELOG.md +21 -0
- package/README.md +122 -8
- package/dist/approval-manager.d.ts +1 -1
- package/dist/async-job-manager.d.ts +53 -4
- package/dist/async-job-manager.js +237 -17
- package/dist/cli-updater.d.ts +38 -0
- package/dist/cli-updater.js +145 -0
- package/dist/flight-recorder.d.ts +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.js +651 -26
- package/dist/job-store.d.ts +84 -0
- package/dist/job-store.js +251 -0
- package/dist/model-registry.d.ts +14 -0
- package/dist/model-registry.js +444 -134
- package/dist/request-helpers.d.ts +41 -0
- package/dist/request-helpers.js +40 -0
- package/dist/resources.js +44 -0
- package/dist/session-manager-pg.js +1 -0
- package/dist/session-manager.d.ts +1 -1
- package/dist/session-manager.js +2 -1
- package/package.json +3 -3
|
@@ -3,9 +3,11 @@ import { randomUUID } from "crypto";
|
|
|
3
3
|
import { getExtendedPath, killProcessGroup, registerProcessGroup, unregisterProcessGroup, } from "./executor.js";
|
|
4
4
|
import { noopLogger } from "./logger.js";
|
|
5
5
|
import { ProcessMonitor } from "./process-monitor.js";
|
|
6
|
+
import { computeRequestKey } from "./job-store.js";
|
|
6
7
|
const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
|
|
7
|
-
const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
8
|
+
const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour in-memory retention; durable store has its own (longer) retention
|
|
8
9
|
const EVICTION_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
|
10
|
+
const OUTPUT_FLUSH_INTERVAL_MS = 1000; // Throttle DB writes for streaming stdout/stderr
|
|
9
11
|
function truncateText(value, maxChars) {
|
|
10
12
|
if (value.length <= maxChars) {
|
|
11
13
|
return { text: value, truncated: false };
|
|
@@ -21,10 +23,23 @@ export class AsyncJobManager {
|
|
|
21
23
|
jobs = new Map();
|
|
22
24
|
evictionTimer = null;
|
|
23
25
|
processMonitor;
|
|
24
|
-
|
|
26
|
+
store;
|
|
27
|
+
constructor(logger = noopLogger, onJobComplete, store = null) {
|
|
25
28
|
this.logger = logger;
|
|
26
29
|
this.onJobComplete = onJobComplete;
|
|
27
30
|
this.processMonitor = new ProcessMonitor(logger);
|
|
31
|
+
this.store = store;
|
|
32
|
+
if (this.store) {
|
|
33
|
+
try {
|
|
34
|
+
const orphaned = this.store.markOrphanedOnStartup();
|
|
35
|
+
if (orphaned > 0) {
|
|
36
|
+
this.logger.info(`Marked ${orphaned} in-flight job(s) as orphaned after gateway restart`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
this.logger.error("markOrphanedOnStartup failed", err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
28
43
|
this.evictionTimer = setInterval(() => this.evictCompletedJobs(), EVICTION_INTERVAL_MS);
|
|
29
44
|
// Allow the process to exit even if the timer is active
|
|
30
45
|
if (this.evictionTimer.unref) {
|
|
@@ -52,7 +67,7 @@ export class AsyncJobManager {
|
|
|
52
67
|
let evicted = 0;
|
|
53
68
|
// Dead process auto-recovery: check for running jobs whose process no longer exists
|
|
54
69
|
for (const [id, job] of this.jobs) {
|
|
55
|
-
if (job.status === "running" && job.process.pid) {
|
|
70
|
+
if (job.status === "running" && job.process && job.process.pid) {
|
|
56
71
|
try {
|
|
57
72
|
process.kill(job.process.pid, 0);
|
|
58
73
|
}
|
|
@@ -66,6 +81,7 @@ export class AsyncJobManager {
|
|
|
66
81
|
unregisterProcessGroup(job.process.pid);
|
|
67
82
|
this.logger.error(`Job ${id} process ${job.process.pid} no longer exists, marking as failed`);
|
|
68
83
|
this.emitMetrics(job);
|
|
84
|
+
this.persistComplete(job);
|
|
69
85
|
}
|
|
70
86
|
// EPERM: process exists but we can't signal it — ignore
|
|
71
87
|
}
|
|
@@ -75,10 +91,11 @@ export class AsyncJobManager {
|
|
|
75
91
|
job.status = "failed";
|
|
76
92
|
job.error = "Process exited without proper status transition";
|
|
77
93
|
job.finishedAt = job.finishedAt || new Date().toISOString();
|
|
78
|
-
if (job.process.pid)
|
|
94
|
+
if (job.process && job.process.pid)
|
|
79
95
|
unregisterProcessGroup(job.process.pid);
|
|
80
96
|
this.logger.error(`Job ${id} has exited flag but was still in running state, marking as failed`);
|
|
81
97
|
this.emitMetrics(job);
|
|
98
|
+
this.persistComplete(job);
|
|
82
99
|
}
|
|
83
100
|
}
|
|
84
101
|
for (const [id, job] of this.jobs) {
|
|
@@ -91,10 +108,178 @@ export class AsyncJobManager {
|
|
|
91
108
|
}
|
|
92
109
|
}
|
|
93
110
|
if (evicted > 0) {
|
|
94
|
-
this.logger.debug(`Evicted ${evicted} completed jobs`);
|
|
111
|
+
this.logger.debug(`Evicted ${evicted} completed jobs from memory (durable store retains them)`);
|
|
112
|
+
}
|
|
113
|
+
// Sweep the durable store, too. Errors are non-fatal — the job rows just stay until next sweep.
|
|
114
|
+
if (this.store) {
|
|
115
|
+
try {
|
|
116
|
+
const removed = this.store.evictExpired();
|
|
117
|
+
if (removed > 0) {
|
|
118
|
+
this.logger.debug(`Evicted ${removed} expired jobs from durable store`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
this.logger.error("durable store eviction failed", err);
|
|
123
|
+
}
|
|
95
124
|
}
|
|
96
125
|
}
|
|
97
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Compute the dedup key for a job. Stable across re-issues of the same request,
|
|
128
|
+
* which is exactly what allows agents to safely retry without restarting the run.
|
|
129
|
+
*/
|
|
130
|
+
buildRequestKey(cli, args) {
|
|
131
|
+
return computeRequestKey(cli, args);
|
|
132
|
+
}
|
|
133
|
+
safeStoreCall(label, fn) {
|
|
134
|
+
if (!this.store)
|
|
135
|
+
return;
|
|
136
|
+
try {
|
|
137
|
+
fn();
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
this.logger.error(`JobStore.${label} failed`, err);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Flush in-memory stdout/stderr to the durable store if anything changed
|
|
145
|
+
* since the last flush. Throttled by OUTPUT_FLUSH_INTERVAL_MS to avoid
|
|
146
|
+
* pounding sqlite on every chunk of streaming output.
|
|
147
|
+
*/
|
|
148
|
+
maybeFlushOutput(job, force = false) {
|
|
149
|
+
if (!this.store)
|
|
150
|
+
return;
|
|
151
|
+
if (!job.outputDirty)
|
|
152
|
+
return;
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
if (!force && now - job.lastOutputFlushAt < OUTPUT_FLUSH_INTERVAL_MS)
|
|
155
|
+
return;
|
|
156
|
+
job.outputDirty = false;
|
|
157
|
+
job.lastOutputFlushAt = now;
|
|
158
|
+
this.safeStoreCall("recordOutput", () => this.store.recordOutput(job.id, job.stdout, job.stderr, job.outputTruncated));
|
|
159
|
+
}
|
|
160
|
+
persistComplete(job) {
|
|
161
|
+
if (!this.store)
|
|
162
|
+
return;
|
|
163
|
+
if (job.status === "running")
|
|
164
|
+
return;
|
|
165
|
+
if (!job.finishedAt)
|
|
166
|
+
return;
|
|
167
|
+
// Make sure the latest output is captured in the same row update.
|
|
168
|
+
job.outputDirty = false;
|
|
169
|
+
this.safeStoreCall("recordComplete", () => this.store.recordComplete({
|
|
170
|
+
id: job.id,
|
|
171
|
+
status: job.status === "running" ? "failed" : job.status,
|
|
172
|
+
exitCode: job.exitCode,
|
|
173
|
+
stdout: job.stdout,
|
|
174
|
+
stderr: job.stderr,
|
|
175
|
+
outputTruncated: job.outputTruncated,
|
|
176
|
+
error: job.error,
|
|
177
|
+
finishedAt: job.finishedAt,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Reconstitute an in-memory AsyncJobRecord from a durable row, so subsequent
|
|
182
|
+
* getJobSnapshot/getJobResult calls hit the in-memory cache.
|
|
183
|
+
* The reconstituted record has process=null — it represents historical data only.
|
|
184
|
+
*/
|
|
185
|
+
hydrateFromStore(jobId) {
|
|
186
|
+
if (!this.store)
|
|
187
|
+
return null;
|
|
188
|
+
let row;
|
|
189
|
+
try {
|
|
190
|
+
row = this.store.getById(jobId);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
this.logger.error("JobStore.getById failed", err);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
if (!row)
|
|
197
|
+
return null;
|
|
198
|
+
const args = (() => {
|
|
199
|
+
try {
|
|
200
|
+
const parsed = JSON.parse(row.argsJson);
|
|
201
|
+
return Array.isArray(parsed) ? parsed.map(String) : [];
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
const reconstituted = {
|
|
208
|
+
id: row.id,
|
|
209
|
+
cli: row.cli,
|
|
210
|
+
args,
|
|
211
|
+
requestKey: row.requestKey,
|
|
212
|
+
correlationId: row.correlationId,
|
|
213
|
+
status: row.status,
|
|
214
|
+
startedAt: row.startedAt,
|
|
215
|
+
finishedAt: row.finishedAt,
|
|
216
|
+
exitCode: row.exitCode,
|
|
217
|
+
stdout: row.stdout,
|
|
218
|
+
stderr: row.stderr,
|
|
219
|
+
outputTruncated: row.outputTruncated,
|
|
220
|
+
canceled: row.status === "canceled",
|
|
221
|
+
error: row.error,
|
|
222
|
+
process: null,
|
|
223
|
+
exited: row.status !== "running",
|
|
224
|
+
metricsRecorded: true,
|
|
225
|
+
outputFormat: row.outputFormat ?? undefined,
|
|
226
|
+
outputDirty: false,
|
|
227
|
+
lastOutputFlushAt: Date.now(),
|
|
228
|
+
};
|
|
229
|
+
this.jobs.set(jobId, reconstituted);
|
|
230
|
+
return reconstituted;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Backwards-compatible entry point. Equivalent to startJobWithDedup({...}).snapshot.
|
|
234
|
+
* Existing callers keep working unchanged; forceRefresh is exposed as a trailing
|
|
235
|
+
* optional param for the dedup-aware path.
|
|
236
|
+
*/
|
|
237
|
+
startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat, forceRefresh) {
|
|
238
|
+
return this.startJobWithDedup(cli, args, correlationId, {
|
|
239
|
+
cwd,
|
|
240
|
+
idleTimeoutMs,
|
|
241
|
+
outputFormat,
|
|
242
|
+
forceRefresh,
|
|
243
|
+
}).snapshot;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Start a job, with optional dedup against recent identical requests.
|
|
247
|
+
* Returns `{ snapshot, deduped }` so callers can log/report the short-circuit.
|
|
248
|
+
*
|
|
249
|
+
* Dedup is keyed on (cli, args). If a job with the same key was started within
|
|
250
|
+
* the dedup window (default 1h) and is still running or completed, its snapshot
|
|
251
|
+
* is returned without spawning a new process. forceRefresh skips dedup entirely.
|
|
252
|
+
*/
|
|
253
|
+
startJobWithDedup(cli, args, correlationId, opts = {}) {
|
|
254
|
+
const { cwd, idleTimeoutMs, outputFormat, forceRefresh } = opts;
|
|
255
|
+
const requestKey = this.buildRequestKey(cli, args);
|
|
256
|
+
if (!forceRefresh && this.store) {
|
|
257
|
+
try {
|
|
258
|
+
const existing = this.store.findByRequestKey(requestKey);
|
|
259
|
+
if (existing) {
|
|
260
|
+
// Prefer the in-memory record if we still have it (live process, idle timers, etc).
|
|
261
|
+
let record = this.jobs.get(existing.id);
|
|
262
|
+
if (!record) {
|
|
263
|
+
record = this.hydrateFromStore(existing.id) ?? undefined;
|
|
264
|
+
}
|
|
265
|
+
if (record) {
|
|
266
|
+
this.logger.info(`Job ${existing.id} reused via dedup for ${cli}`, {
|
|
267
|
+
correlationId,
|
|
268
|
+
originalCorrelationId: record.correlationId,
|
|
269
|
+
status: record.status,
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
snapshot: this.snapshot(record),
|
|
273
|
+
deduped: true,
|
|
274
|
+
originalCorrelationId: record.correlationId,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
this.logger.error("dedup lookup failed; proceeding with fresh run", err);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
98
283
|
const id = randomUUID();
|
|
99
284
|
const startedAt = new Date().toISOString();
|
|
100
285
|
const child = spawn(cli, args, {
|
|
@@ -119,6 +304,7 @@ export class AsyncJobManager {
|
|
|
119
304
|
id,
|
|
120
305
|
cli,
|
|
121
306
|
args: [...args],
|
|
307
|
+
requestKey,
|
|
122
308
|
correlationId,
|
|
123
309
|
status: "running",
|
|
124
310
|
startedAt,
|
|
@@ -134,8 +320,20 @@ export class AsyncJobManager {
|
|
|
134
320
|
metricsRecorded: false,
|
|
135
321
|
outputFormat,
|
|
136
322
|
cleanupGroup,
|
|
323
|
+
outputDirty: false,
|
|
324
|
+
lastOutputFlushAt: Date.now(),
|
|
137
325
|
};
|
|
138
326
|
this.jobs.set(id, job);
|
|
327
|
+
this.safeStoreCall("recordStart", () => this.store.recordStart({
|
|
328
|
+
id,
|
|
329
|
+
correlationId,
|
|
330
|
+
requestKey,
|
|
331
|
+
cli,
|
|
332
|
+
args: [...args],
|
|
333
|
+
outputFormat,
|
|
334
|
+
startedAt,
|
|
335
|
+
pid: child.pid ?? null,
|
|
336
|
+
}));
|
|
139
337
|
this.logger.info(`Job ${id} started for ${cli}`, { correlationId });
|
|
140
338
|
// Idle timeout: kill process if no output activity for idleTimeoutMs
|
|
141
339
|
let idleTimerId;
|
|
@@ -151,13 +349,15 @@ export class AsyncJobManager {
|
|
|
151
349
|
job.exitCode = 125;
|
|
152
350
|
job.error = `Process killed after ${idleTimeoutMs}ms of inactivity`;
|
|
153
351
|
job.finishedAt = new Date().toISOString();
|
|
154
|
-
|
|
352
|
+
if (job.process)
|
|
353
|
+
killProcessGroup(job.process, "SIGTERM");
|
|
155
354
|
this.logger.info(`Job ${id} killed due to inactivity (${idleTimeoutMs}ms)`, {
|
|
156
355
|
correlationId,
|
|
157
356
|
});
|
|
158
357
|
this.emitMetrics(job);
|
|
358
|
+
this.persistComplete(job);
|
|
159
359
|
setTimeout(() => {
|
|
160
|
-
if (!job.exited)
|
|
360
|
+
if (!job.exited && job.process)
|
|
161
361
|
killProcessGroup(job.process, "SIGKILL");
|
|
162
362
|
job.cleanupGroup?.();
|
|
163
363
|
}, 5000);
|
|
@@ -185,6 +385,7 @@ export class AsyncJobManager {
|
|
|
185
385
|
job.finishedAt = new Date().toISOString();
|
|
186
386
|
this.logger.error(`Job ${id} error: ${error.message}`, { correlationId });
|
|
187
387
|
this.emitMetrics(job);
|
|
388
|
+
this.persistComplete(job);
|
|
188
389
|
}
|
|
189
390
|
});
|
|
190
391
|
child.on("close", (code) => {
|
|
@@ -199,6 +400,8 @@ export class AsyncJobManager {
|
|
|
199
400
|
if (!job.finishedAt) {
|
|
200
401
|
job.finishedAt = new Date().toISOString();
|
|
201
402
|
}
|
|
403
|
+
// Ensure terminal state reaches the durable store (idle-timeout/output-overflow already persisted).
|
|
404
|
+
this.persistComplete(job);
|
|
202
405
|
return;
|
|
203
406
|
}
|
|
204
407
|
job.exitCode = code ?? 0;
|
|
@@ -213,20 +416,25 @@ export class AsyncJobManager {
|
|
|
213
416
|
job.status = "failed";
|
|
214
417
|
}
|
|
215
418
|
this.emitMetrics(job);
|
|
419
|
+
this.persistComplete(job);
|
|
216
420
|
});
|
|
217
|
-
return this.snapshot(job);
|
|
421
|
+
return { snapshot: this.snapshot(job), deduped: false };
|
|
218
422
|
}
|
|
219
423
|
getJobSnapshot(jobId) {
|
|
220
|
-
|
|
424
|
+
let job = this.jobs.get(jobId);
|
|
221
425
|
if (!job) {
|
|
222
|
-
|
|
426
|
+
job = this.hydrateFromStore(jobId) ?? undefined;
|
|
427
|
+
if (!job)
|
|
428
|
+
return null;
|
|
223
429
|
}
|
|
224
430
|
return this.snapshot(job);
|
|
225
431
|
}
|
|
226
432
|
getJobResult(jobId, maxChars = 200000) {
|
|
227
|
-
|
|
433
|
+
let job = this.jobs.get(jobId);
|
|
228
434
|
if (!job) {
|
|
229
|
-
|
|
435
|
+
job = this.hydrateFromStore(jobId) ?? undefined;
|
|
436
|
+
if (!job)
|
|
437
|
+
return null;
|
|
230
438
|
}
|
|
231
439
|
const stdout = truncateText(job.stdout, maxChars);
|
|
232
440
|
const stderr = truncateText(job.stderr, maxChars);
|
|
@@ -246,14 +454,22 @@ export class AsyncJobManager {
|
|
|
246
454
|
if (job.status !== "running") {
|
|
247
455
|
return { canceled: false, reason: `Job is already ${job.status}` };
|
|
248
456
|
}
|
|
457
|
+
// Reconstituted (orphaned) jobs have no live process to signal — refuse cancel.
|
|
458
|
+
if (!job.process) {
|
|
459
|
+
return {
|
|
460
|
+
canceled: false,
|
|
461
|
+
reason: "Job has no live process (orphaned from prior gateway run)",
|
|
462
|
+
};
|
|
463
|
+
}
|
|
249
464
|
job.canceled = true;
|
|
250
465
|
job.status = "canceled";
|
|
251
466
|
job.finishedAt = new Date().toISOString();
|
|
252
467
|
job.clearIdleTimer?.();
|
|
253
468
|
killProcessGroup(job.process, "SIGTERM");
|
|
254
469
|
this.logger.info(`Job ${jobId} canceled`, { correlationId: job.correlationId });
|
|
470
|
+
this.persistComplete(job);
|
|
255
471
|
setTimeout(() => {
|
|
256
|
-
if (!job.exited)
|
|
472
|
+
if (!job.exited && job.process)
|
|
257
473
|
killProcessGroup(job.process, "SIGKILL");
|
|
258
474
|
job.cleanupGroup?.();
|
|
259
475
|
}, 5000);
|
|
@@ -267,7 +483,7 @@ export class AsyncJobManager {
|
|
|
267
483
|
jobId: id,
|
|
268
484
|
cli: job.cli,
|
|
269
485
|
status: job.status,
|
|
270
|
-
pid: job.process
|
|
486
|
+
pid: job.process?.pid ?? null,
|
|
271
487
|
startedAt: job.startedAt,
|
|
272
488
|
});
|
|
273
489
|
}
|
|
@@ -316,13 +532,15 @@ export class AsyncJobManager {
|
|
|
316
532
|
job.error = "Output exceeded maximum size (50MB)";
|
|
317
533
|
job.finishedAt = new Date().toISOString();
|
|
318
534
|
job.clearIdleTimer?.();
|
|
319
|
-
|
|
535
|
+
if (job.process)
|
|
536
|
+
killProcessGroup(job.process, "SIGTERM");
|
|
320
537
|
this.logger.info(`Job ${job.id} killed due to output overflow`, {
|
|
321
538
|
correlationId: job.correlationId,
|
|
322
539
|
});
|
|
323
540
|
this.emitMetrics(job);
|
|
541
|
+
this.persistComplete(job);
|
|
324
542
|
setTimeout(() => {
|
|
325
|
-
if (!job.exited)
|
|
543
|
+
if (!job.exited && job.process)
|
|
326
544
|
killProcessGroup(job.process, "SIGKILL");
|
|
327
545
|
job.cleanupGroup?.();
|
|
328
546
|
}, 5000);
|
|
@@ -337,5 +555,7 @@ export class AsyncJobManager {
|
|
|
337
555
|
else {
|
|
338
556
|
job.stderr += text;
|
|
339
557
|
}
|
|
558
|
+
job.outputDirty = true;
|
|
559
|
+
this.maybeFlushOutput(job);
|
|
340
560
|
}
|
|
341
561
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Logger } from "./logger.js";
|
|
2
|
+
import type { CliType } from "./session-manager.js";
|
|
3
|
+
export interface CliVersionInfo {
|
|
4
|
+
cli: CliType;
|
|
5
|
+
command: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
installed: boolean;
|
|
8
|
+
version?: string;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface CliUpgradePlan {
|
|
14
|
+
cli: CliType;
|
|
15
|
+
target: string;
|
|
16
|
+
command: string;
|
|
17
|
+
args: string[];
|
|
18
|
+
strategy: "self-update" | "npm-global-install";
|
|
19
|
+
requiresNetwork: boolean;
|
|
20
|
+
note?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface CliUpgradeResult {
|
|
23
|
+
dryRun: boolean;
|
|
24
|
+
plan: CliUpgradePlan;
|
|
25
|
+
stdout?: string;
|
|
26
|
+
stderr?: string;
|
|
27
|
+
exitCode?: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function buildCliUpgradePlan(cli: CliType, target?: string): CliUpgradePlan;
|
|
30
|
+
export declare function getCliVersion(cli: CliType): Promise<CliVersionInfo>;
|
|
31
|
+
export declare function getCliVersions(cli?: CliType): Promise<CliVersionInfo[]>;
|
|
32
|
+
export declare function runCliUpgrade(params: {
|
|
33
|
+
cli: CliType;
|
|
34
|
+
target?: string;
|
|
35
|
+
dryRun: boolean;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
logger?: Logger;
|
|
38
|
+
}): Promise<CliUpgradeResult>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { executeCli } from "./executor.js";
|
|
2
|
+
const VERSION_ARGS = {
|
|
3
|
+
claude: ["--version"],
|
|
4
|
+
codex: ["--version"],
|
|
5
|
+
gemini: ["--version"],
|
|
6
|
+
grok: ["--version"],
|
|
7
|
+
};
|
|
8
|
+
const NPM_PACKAGES = {
|
|
9
|
+
codex: "@openai/codex",
|
|
10
|
+
gemini: "@google/gemini-cli",
|
|
11
|
+
};
|
|
12
|
+
export function buildCliUpgradePlan(cli, target = "latest") {
|
|
13
|
+
const normalizedTarget = normalizeTarget(target);
|
|
14
|
+
if (cli === "claude") {
|
|
15
|
+
if (normalizedTarget === "latest") {
|
|
16
|
+
return {
|
|
17
|
+
cli,
|
|
18
|
+
target: normalizedTarget,
|
|
19
|
+
command: "claude",
|
|
20
|
+
args: ["update"],
|
|
21
|
+
strategy: "self-update",
|
|
22
|
+
requiresNetwork: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
cli,
|
|
27
|
+
target: normalizedTarget,
|
|
28
|
+
command: "claude",
|
|
29
|
+
args: ["install", normalizedTarget],
|
|
30
|
+
strategy: "self-update",
|
|
31
|
+
requiresNetwork: true,
|
|
32
|
+
note: "Claude Code supports explicit install targets through 'claude install <target>'.",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (cli === "grok") {
|
|
36
|
+
if (normalizedTarget === "latest") {
|
|
37
|
+
return {
|
|
38
|
+
cli,
|
|
39
|
+
target: normalizedTarget,
|
|
40
|
+
command: "grok",
|
|
41
|
+
args: ["update"],
|
|
42
|
+
strategy: "self-update",
|
|
43
|
+
requiresNetwork: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
cli,
|
|
48
|
+
target: normalizedTarget,
|
|
49
|
+
command: "grok",
|
|
50
|
+
args: ["update", "--version", normalizedTarget],
|
|
51
|
+
strategy: "self-update",
|
|
52
|
+
requiresNetwork: true,
|
|
53
|
+
note: "Grok CLI supports explicit version targets via 'grok update --version <target>'.",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (cli === "codex" && normalizedTarget === "latest") {
|
|
57
|
+
return {
|
|
58
|
+
cli,
|
|
59
|
+
target: normalizedTarget,
|
|
60
|
+
command: "codex",
|
|
61
|
+
args: ["update"],
|
|
62
|
+
strategy: "self-update",
|
|
63
|
+
requiresNetwork: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const packageName = cli === "codex" ? NPM_PACKAGES.codex : NPM_PACKAGES.gemini;
|
|
67
|
+
return {
|
|
68
|
+
cli,
|
|
69
|
+
target: normalizedTarget,
|
|
70
|
+
command: "npm",
|
|
71
|
+
args: ["install", "-g", `${packageName}@${normalizedTarget}`],
|
|
72
|
+
strategy: "npm-global-install",
|
|
73
|
+
requiresNetwork: true,
|
|
74
|
+
note: cli === "codex"
|
|
75
|
+
? "Explicit Codex targets use the documented npm package path; latest can use 'codex update'."
|
|
76
|
+
: "Gemini CLI does not expose a self-update command in the gateway-supported CLI surface, so upgrades use npm.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export async function getCliVersion(cli) {
|
|
80
|
+
const args = VERSION_ARGS[cli];
|
|
81
|
+
try {
|
|
82
|
+
const result = await executeCli(cli, args, { timeout: 15_000 });
|
|
83
|
+
return {
|
|
84
|
+
cli,
|
|
85
|
+
command: cli,
|
|
86
|
+
args,
|
|
87
|
+
installed: true,
|
|
88
|
+
version: extractVersion(result.stdout, result.stderr),
|
|
89
|
+
stdout: result.stdout,
|
|
90
|
+
stderr: result.stderr,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
95
|
+
return {
|
|
96
|
+
cli,
|
|
97
|
+
command: cli,
|
|
98
|
+
args,
|
|
99
|
+
installed: false,
|
|
100
|
+
stdout: "",
|
|
101
|
+
stderr: "",
|
|
102
|
+
error: message,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export async function getCliVersions(cli) {
|
|
107
|
+
const clis = cli ? [cli] : ["claude", "codex", "gemini", "grok"];
|
|
108
|
+
return Promise.all(clis.map(item => getCliVersion(item)));
|
|
109
|
+
}
|
|
110
|
+
export async function runCliUpgrade(params) {
|
|
111
|
+
const plan = buildCliUpgradePlan(params.cli, params.target);
|
|
112
|
+
if (params.dryRun) {
|
|
113
|
+
return { dryRun: true, plan };
|
|
114
|
+
}
|
|
115
|
+
params.logger?.info(`Upgrading ${params.cli} CLI`, {
|
|
116
|
+
target: plan.target,
|
|
117
|
+
command: plan.command,
|
|
118
|
+
args: plan.args,
|
|
119
|
+
});
|
|
120
|
+
const result = await executeCli(plan.command, plan.args, {
|
|
121
|
+
timeout: params.timeoutMs ?? 600_000,
|
|
122
|
+
logger: params.logger,
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
dryRun: false,
|
|
126
|
+
plan,
|
|
127
|
+
stdout: result.stdout,
|
|
128
|
+
stderr: result.stderr,
|
|
129
|
+
exitCode: result.code,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function normalizeTarget(target) {
|
|
133
|
+
const normalized = target.trim();
|
|
134
|
+
if (!normalized || normalized.startsWith("-") || /[\u0000-\u001f\u007f\s]/.test(normalized)) {
|
|
135
|
+
throw new Error("Upgrade target must be a non-empty package tag or version without whitespace and cannot start with '-'");
|
|
136
|
+
}
|
|
137
|
+
return normalized;
|
|
138
|
+
}
|
|
139
|
+
function extractVersion(stdout, stderr) {
|
|
140
|
+
const text = `${stdout}\n${stderr}`
|
|
141
|
+
.split(/\r?\n/)
|
|
142
|
+
.map(line => line.trim())
|
|
143
|
+
.find(line => line.length > 0);
|
|
144
|
+
return text || undefined;
|
|
145
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ export interface GeminiRequestParams {
|
|
|
37
37
|
optimizePrompt: boolean;
|
|
38
38
|
optimizeResponse?: boolean;
|
|
39
39
|
idleTimeoutMs?: number;
|
|
40
|
+
forceRefresh?: boolean;
|
|
40
41
|
}
|
|
41
42
|
export interface HandlerDeps {
|
|
42
43
|
sessionManager: ISessionManager;
|
|
@@ -51,6 +52,30 @@ export interface AsyncHandlerDeps extends HandlerDeps {
|
|
|
51
52
|
}
|
|
52
53
|
export declare function handleGeminiRequest(deps: HandlerDeps, params: GeminiRequestParams): Promise<ExtendedToolResponse>;
|
|
53
54
|
export declare function handleGeminiRequestAsync(deps: AsyncHandlerDeps, params: Omit<GeminiRequestParams, "optimizeResponse">): Promise<ExtendedToolResponse>;
|
|
55
|
+
export interface GrokRequestParams {
|
|
56
|
+
prompt: string;
|
|
57
|
+
model?: string;
|
|
58
|
+
outputFormat?: string;
|
|
59
|
+
sessionId?: string;
|
|
60
|
+
resumeLatest: boolean;
|
|
61
|
+
createNewSession: boolean;
|
|
62
|
+
alwaysApprove?: boolean;
|
|
63
|
+
permissionMode?: string;
|
|
64
|
+
effort?: string;
|
|
65
|
+
reasoningEffort?: string;
|
|
66
|
+
approvalStrategy: "legacy" | "mcp_managed";
|
|
67
|
+
approvalPolicy?: string;
|
|
68
|
+
mcpServers?: ClaudeMcpServerName[];
|
|
69
|
+
allowedTools?: string[];
|
|
70
|
+
disallowedTools?: string[];
|
|
71
|
+
correlationId?: string;
|
|
72
|
+
optimizePrompt: boolean;
|
|
73
|
+
optimizeResponse?: boolean;
|
|
74
|
+
idleTimeoutMs?: number;
|
|
75
|
+
forceRefresh?: boolean;
|
|
76
|
+
}
|
|
77
|
+
export declare function handleGrokRequest(deps: HandlerDeps, params: GrokRequestParams): Promise<ExtendedToolResponse>;
|
|
78
|
+
export declare function handleGrokRequestAsync(deps: AsyncHandlerDeps, params: Omit<GrokRequestParams, "optimizeResponse">): Promise<ExtendedToolResponse>;
|
|
54
79
|
export declare function handleCodexRequestAsync(deps: AsyncHandlerDeps, params: {
|
|
55
80
|
prompt: string;
|
|
56
81
|
model?: string;
|
|
@@ -60,9 +85,11 @@ export declare function handleCodexRequestAsync(deps: AsyncHandlerDeps, params:
|
|
|
60
85
|
approvalPolicy?: string;
|
|
61
86
|
mcpServers?: ClaudeMcpServerName[];
|
|
62
87
|
sessionId?: string;
|
|
88
|
+
resumeLatest?: boolean;
|
|
63
89
|
createNewSession: boolean;
|
|
64
90
|
correlationId?: string;
|
|
65
91
|
optimizePrompt: boolean;
|
|
66
92
|
idleTimeoutMs?: number;
|
|
93
|
+
forceRefresh?: boolean;
|
|
67
94
|
}): Promise<ExtendedToolResponse>;
|
|
68
95
|
export {};
|