openlimiter 1.2.0 → 1.3.1
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/README.md +45 -16
- package/dist/bin.js +53 -4
- package/dist/bin.js.map +1 -1
- package/dist/cli.d.ts +110 -11
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1064 -64
- package/dist/cli.js.map +1 -1
- package/dist/codex-device-login.d.ts +87 -0
- package/dist/codex-device-login.d.ts.map +1 -0
- package/dist/codex-device-login.js +291 -0
- package/dist/codex-device-login.js.map +1 -0
- package/dist/config.d.ts +44 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +129 -7
- package/dist/config.js.map +1 -1
- package/dist/hub-auth.d.ts +137 -0
- package/dist/hub-auth.d.ts.map +1 -0
- package/dist/hub-auth.js +280 -0
- package/dist/hub-auth.js.map +1 -0
- package/dist/hub-sync.d.ts +144 -0
- package/dist/hub-sync.d.ts.map +1 -0
- package/dist/hub-sync.js +370 -0
- package/dist/hub-sync.js.map +1 -0
- package/dist/hub.d.ts +60 -0
- package/dist/hub.d.ts.map +1 -0
- package/dist/hub.js +178 -0
- package/dist/hub.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/ingest.d.ts +26 -0
- package/dist/ingest.d.ts.map +1 -1
- package/dist/ingest.js +116 -1
- package/dist/ingest.js.map +1 -1
- package/dist/qr.d.ts.map +1 -1
- package/dist/qr.js +1 -0
- package/dist/qr.js.map +1 -1
- package/dist/session.d.ts +46 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +121 -0
- package/dist/session.js.map +1 -0
- package/dist/statusline.d.ts +14 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +258 -19
- package/dist/statusline.js.map +1 -1
- package/dist/terminal.d.ts +109 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +974 -0
- package/dist/terminal.js.map +1 -0
- package/package.json +4 -4
- package/dist/serve.d.ts +0 -170
- package/dist/serve.d.ts.map +0 -1
- package/dist/serve.js +0 -533
- package/dist/serve.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import { PROVIDER_CODES, buildAdvice, canonicalJson, dedupeFailures, failureFromConnectorReason, freshness, mergeSnapshots, normalizeMeters, normalizeMetersReport, readSnapshotCache } from "@openlimiter/core";
|
|
2
|
-
import { antigravityFixture, claudeFixture, codexFixture, grokFixture, kimiFixture, connectors, manualFixture, opencodeFixture, openrouterFixture, parseAntigravityPayload, parseClaudePayload, parseCodexPayload, parseGrokPayload, parseKimiPayload, parseManualPayload, parseOpencodePayload, parseOpenrouterPayload } from "@openlimiter/connectors";
|
|
1
|
+
import { ACQUISITION_OUTCOME_SENTENCE, CREDENTIAL_FAILURE_SENTENCE, PROVIDER_CODES, acquireRefreshLock, clearRefreshSpawnFailure, mergeAcquiredSnapshots, createFetchTransport, readRefreshSpawnFailure, antigravitySpec, buildAdvice, canonicalJson, claudeSpec, codexSpec, dedupeFailures, desktopHoldsCache, failureFromConnectorReason, freshness, geminiCliSpec, grokSpec, isProviderDue, kimiSpec, mergeSnapshots, normalizeMeters, normalizeMetersReport, openrouterSpec, readAcquisitionCredential, readAcquisitionSchedule, readSnapshotCache, readWindowsCredentialWith, resolveStateDirectory, runAcquisition, spawnDetachedRefresh, writeAcquisitionSchedule, probeAntigravity, resolveAgyExecutablePath } from "@openlimiter/core";
|
|
2
|
+
import { antigravityFixture, claudeFixture, codexFixture, grokFixture, kimiFixture, connectors, manualFixture, opencodeFixture, openrouterFixture, parseAntigravityCodeAssistPayload, parseAntigravityPayload, parseClaudePayload, parseCodexPayload, parseGeminiCliPayload, parseGrokPayload, parseKimiPayload, parseManualPayload, parseOpencodePayload, parseOpenrouterPayload } from "@openlimiter/connectors";
|
|
3
3
|
import { AGENT_COMPATIBILITY, agentContextFromCache, agentContextSpillFromCache, agentVersionCompatibility, changeAgentHook, detectAgentInstallation, readAgentHookStatus, runAgentHook, validateAgentExecutableStamp, writeAgentContextSnapshot, loadHostedContextTrust, renderClaudeStatusline } from "@openlimiter/adapters";
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
4
6
|
import { homedir } from "node:os";
|
|
5
|
-
import { STATUSLINE_KEYS, defaultConfig, initialize, isStatuslineKey, readConfig, readStatuslineConfig, setStatuslineValue, statuslineValueText, writeConfig } from "./config.js";
|
|
6
|
-
import { INGEST_PROVENANCE, MANUAL_PROVENANCE, STATUSLINE_PROVENANCE, environmentWithLocalMarkers, STDIN_BYTE_LIMIT, parseJsonText, persistSnapshots, readManualDocument, withProvenance } from "./ingest.js";
|
|
7
|
+
import { PROVIDER_KEYS, STATUSLINE_KEYS, defaultConfig, initialize, isProviderKey, isStatuslineKey, providerValueText, readConfig, readProvidersConfig, readStatuslineConfig, setProviderValue, setStatuslineValue, statuslineValueText, writeConfig } from "./config.js";
|
|
8
|
+
import { ACQUISITION_PROVENANCE, ANTIGRAVITY_STATUSLINE_PROVENANCE, GROK_STATUSLINE_PROVENANCE, INGEST_PROVENANCE, MANUAL_PROVENANCE, STATUSLINE_PROVENANCE, environmentWithLocalMarkers, STDIN_BYTE_LIMIT, parseAntigravityStatuslinePayload, parseGrokStatuslinePayload, parseJsonText, persistSnapshots, readManualDocument, withProvenance } from "./ingest.js";
|
|
7
9
|
import { UnavailableCredentialStore } from "./credentials.js";
|
|
8
|
-
import { DEFAULT_SERVE_PORT, serveBanner, startQuotaServer } from "./serve.js";
|
|
9
10
|
import { failureLine, failureLines, renderTable, supportsColor } from "./render.js";
|
|
10
|
-
import { renderStatuslineLayout, statuslineColor } from "./statusline.js";
|
|
11
|
+
import { isStatuslineHost, renderStatuslineLayout, statuslineColor } from "./statusline.js";
|
|
12
|
+
import { TERMINAL_HOST_NAMES, installHost, terminalHide, terminalShow, terminalStatusTable, uninstallHost } from "./terminal.js";
|
|
13
|
+
import { createFetchHubTransport, hubConfigured } from "./hub.js";
|
|
14
|
+
import { REVOKED_SENTENCE, ensureFreshSession, isAborted, runDeviceLogin } from "./hub-auth.js";
|
|
15
|
+
import { runSync } from "./hub-sync.js";
|
|
16
|
+
import { deleteSession, readSession, writeSession } from "./session.js";
|
|
17
|
+
import { DeviceLoginError, LOGIN_TIMEOUT_MILLISECONDS, SystemDeviceLoginRunner, managedCodexHome, startCodexDeviceLogin, versionIsSupported as codexVersionIsSupported } from "./codex-device-login.js";
|
|
11
18
|
/** Exit codes. Enumerated so a script can tell these cases apart. */
|
|
12
19
|
export const EXIT_OK = 0;
|
|
13
20
|
export const EXIT_FAILURE = 1;
|
|
@@ -26,7 +33,111 @@ function defaults() {
|
|
|
26
33
|
openLimiterScript: process.argv[1] ?? "",
|
|
27
34
|
nodeExecutable: process.execPath,
|
|
28
35
|
platform: process.platform,
|
|
29
|
-
detectedAgentInstallations: {}
|
|
36
|
+
detectedAgentInstallations: {},
|
|
37
|
+
/*
|
|
38
|
+
* The library defaults reach nothing outside this process, exactly as the
|
|
39
|
+
* standard input reader above does. The executable injects the real
|
|
40
|
+
* transport, the real spawner and the real credential helper, so a test or
|
|
41
|
+
* another tool that calls runCli in process opens no socket, starts no
|
|
42
|
+
* child and runs no shell unless it asked for one.
|
|
43
|
+
*/
|
|
44
|
+
acquisitionTransport: async () => {
|
|
45
|
+
throw new Error("No acquisition transport was injected");
|
|
46
|
+
},
|
|
47
|
+
spawnDetached: () => undefined,
|
|
48
|
+
probeAntigravity: async () => ({ ok: false, reason: "not_running" }),
|
|
49
|
+
promptChoice: async () => "",
|
|
50
|
+
hubTransport: async () => {
|
|
51
|
+
throw new Error("No hub transport was injected");
|
|
52
|
+
},
|
|
53
|
+
openBrowser: () => undefined,
|
|
54
|
+
sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
55
|
+
emit: () => undefined,
|
|
56
|
+
codexDeviceLoginRunnerFactory: () => ({
|
|
57
|
+
start: async () => {
|
|
58
|
+
throw new DeviceLoginError("spawn");
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** The line separator every multi line report in this file joins on. */
|
|
64
|
+
const NEWLINE = "\n";
|
|
65
|
+
/**
|
|
66
|
+
* The dependencies that actually reach the world, built once for the executable.
|
|
67
|
+
*
|
|
68
|
+
* Exported rather than inlined in the executable because the executable has two
|
|
69
|
+
* entry paths, the ordinary one and the wrapped status line, and the wrapped one
|
|
70
|
+
* used to build its own smaller set. A wrapped status line therefore rendered
|
|
71
|
+
* bars forever and never started the refresh that keeps them true, which is the
|
|
72
|
+
* one configuration a person following the install instructions ends up with.
|
|
73
|
+
* One factory, both paths, and a test can assert what is in it.
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* Run one helper executable with arguments and a timeout, and hand back its
|
|
77
|
+
* standard output or a plain failure.
|
|
78
|
+
*
|
|
79
|
+
* Shared by two dependencies that otherwise have nothing to do with each
|
|
80
|
+
* other: reading one Windows Credential Manager entry, and applying an owner
|
|
81
|
+
* only ACL to the session file. Both are "run this helper, get its stdout or
|
|
82
|
+
* nothing back", so both get the same runner rather than two copies of the
|
|
83
|
+
* same `execFile` wrapper.
|
|
84
|
+
*/
|
|
85
|
+
const execFileRunner = async (executable, helperArguments, timeoutMilliseconds) => await new Promise((resolve) => {
|
|
86
|
+
execFile(executable, [...helperArguments], { timeout: timeoutMilliseconds, maxBuffer: 262_144, windowsHide: true }, (error, stdout) => {
|
|
87
|
+
resolve(error === null ? { ok: true, stdout } : { ok: false });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
/** Open a URL in the person's browser, best effort and never awaited. */
|
|
91
|
+
function openBrowserPlatform(url, platform) {
|
|
92
|
+
if (!/^https:\/\//u.test(url) || url.length > 2_048)
|
|
93
|
+
return;
|
|
94
|
+
const [command, commandArguments] = platform === "win32"
|
|
95
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
96
|
+
: platform === "darwin"
|
|
97
|
+
? ["open", [url]]
|
|
98
|
+
: ["xdg-open", [url]];
|
|
99
|
+
try {
|
|
100
|
+
const child = spawn(command, commandArguments, { stdio: "ignore", detached: true });
|
|
101
|
+
child.once("error", () => undefined);
|
|
102
|
+
child.unref();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* The code and the address are already printed, which is enough on its
|
|
106
|
+
own to sign in from. Opening a tab is a convenience, not the path. */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function runtimeDependencies() {
|
|
110
|
+
return {
|
|
111
|
+
acquisitionTransport: createFetchTransport(),
|
|
112
|
+
probeAntigravity,
|
|
113
|
+
resolveExecutablePath: resolveAgyExecutablePath,
|
|
114
|
+
spawnDetached: (executable, argumentsList, options) => {
|
|
115
|
+
const child = spawn(executable, [...argumentsList], {
|
|
116
|
+
detached: true,
|
|
117
|
+
stdio: "ignore",
|
|
118
|
+
windowsHide: true
|
|
119
|
+
});
|
|
120
|
+
/*
|
|
121
|
+
* A detached spawn reports a missing executable asynchronously, long
|
|
122
|
+
* after this function returned and usually after the render has already
|
|
123
|
+
* been printed. Without this listener that arrives as an unhandled error
|
|
124
|
+
* event and takes the host process down with it, which for a status line
|
|
125
|
+
* means taking down somebody's terminal prompt.
|
|
126
|
+
*/
|
|
127
|
+
child.once("error", () => {
|
|
128
|
+
options.onError?.();
|
|
129
|
+
});
|
|
130
|
+
/* Unreferenced so this process can exit while the refresh continues. */
|
|
131
|
+
child.unref();
|
|
132
|
+
},
|
|
133
|
+
windowsCredentialRunner: execFileRunner,
|
|
134
|
+
windowsAclRunner: execFileRunner,
|
|
135
|
+
hubTransport: createFetchHubTransport(),
|
|
136
|
+
openBrowser: (url) => openBrowserPlatform(url, process.platform),
|
|
137
|
+
emit: (line) => {
|
|
138
|
+
process.stdout.write(line + "\n");
|
|
139
|
+
},
|
|
140
|
+
codexDeviceLoginRunnerFactory: (executable) => new SystemDeviceLoginRunner(executable)
|
|
30
141
|
};
|
|
31
142
|
}
|
|
32
143
|
function succeed(stdout) {
|
|
@@ -125,6 +236,346 @@ async function refresh(dependencies, now) {
|
|
|
125
236
|
const persisted = await persistSnapshots(report.snapshots, dependencies.stateDirectory, now);
|
|
126
237
|
return { snapshots: persisted.merged, failures };
|
|
127
238
|
}
|
|
239
|
+
/* ---------------------------------------------------------- acquisition */
|
|
240
|
+
/**
|
|
241
|
+
* How a terminal with no desktop app gets fresh bars.
|
|
242
|
+
*
|
|
243
|
+
* Everything below is wiring, and only wiring. Credential discovery, the closed
|
|
244
|
+
* endpoint table, the cadence, the backoff and the lock all live in the core
|
|
245
|
+
* package, and the parsers all live in the connector package; this is the one
|
|
246
|
+
* place that knows both exist. That separation is why a test can prove a whole
|
|
247
|
+
* round without a socket and without a real login.
|
|
248
|
+
*/
|
|
249
|
+
/**
|
|
250
|
+
* The specifications this machine will run, wired to the real parsers.
|
|
251
|
+
*
|
|
252
|
+
* Claude is here whatever the switch says, because a row that is off still owes
|
|
253
|
+
* a person the sentence explaining why its bar is older than the rest. Every
|
|
254
|
+
* other provider is unconditional: a provider with no local login simply
|
|
255
|
+
* reports that it has none.
|
|
256
|
+
*/
|
|
257
|
+
export function acquisitionSpecs(providers) {
|
|
258
|
+
return [
|
|
259
|
+
claudeSpec({ parse: parseClaudePayload, enabled: providers.claude.poll }),
|
|
260
|
+
codexSpec(parseCodexPayload),
|
|
261
|
+
geminiCliSpec(parseGeminiCliPayload),
|
|
262
|
+
antigravitySpec(parseAntigravityCodeAssistPayload),
|
|
263
|
+
grokSpec(parseGrokPayload),
|
|
264
|
+
kimiSpec(parseKimiPayload),
|
|
265
|
+
openrouterSpec(parseOpenrouterPayload)
|
|
266
|
+
];
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Where each provider's credential is read from.
|
|
270
|
+
*
|
|
271
|
+
* Six of them belong to a vendor's own client and are read off disk, unchanged,
|
|
272
|
+
* by the core. OpenRouter is the exception and always was: its key is one a
|
|
273
|
+
* person typed into this tool, so it lives in the operating system credential
|
|
274
|
+
* store under this product's own name and is read from there.
|
|
275
|
+
*/
|
|
276
|
+
function credentialReader(dependencies) {
|
|
277
|
+
const runner = dependencies.windowsCredentialRunner;
|
|
278
|
+
return async (provider) => {
|
|
279
|
+
if (provider === "OPENROUTER") {
|
|
280
|
+
let secret;
|
|
281
|
+
try {
|
|
282
|
+
secret = await dependencies.credentialStore.get("openlimiter", "openrouter");
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return { ok: false, reason: "unreadable" };
|
|
286
|
+
}
|
|
287
|
+
/*
|
|
288
|
+
* The environment is the second place, and today it is the only one that
|
|
289
|
+
* works in a published build: this package ships no credential store
|
|
290
|
+
* driver, so the store above always answers nothing until one is wired.
|
|
291
|
+
* A terminal person therefore has one documented way to supply the key,
|
|
292
|
+
* and it is the same variable a shell profile already knows how to keep.
|
|
293
|
+
*/
|
|
294
|
+
secret ??= dependencies.environment["OPENLIMITER_OPENROUTER_KEY"] ?? null;
|
|
295
|
+
return secret === null || secret === ""
|
|
296
|
+
? { ok: false, reason: "absent" }
|
|
297
|
+
: {
|
|
298
|
+
ok: true,
|
|
299
|
+
credential: {
|
|
300
|
+
secret,
|
|
301
|
+
accountId: null,
|
|
302
|
+
expiresAtMilliseconds: null,
|
|
303
|
+
origin: "user_key"
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return await readAcquisitionCredential(provider, {
|
|
308
|
+
platform: dependencies.platform,
|
|
309
|
+
environment: dependencies.environment,
|
|
310
|
+
homeDirectory: dependencies.homeDirectory,
|
|
311
|
+
now: dependencies.now(),
|
|
312
|
+
...(runner === undefined || dependencies.platform !== "win32"
|
|
313
|
+
? {}
|
|
314
|
+
: {
|
|
315
|
+
readWindowsCredential: async (target) => await readWindowsCredentialWith(target, { runCommand: runner })
|
|
316
|
+
})
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* What is written onto every acquired reading before it is validated.
|
|
322
|
+
*
|
|
323
|
+
* Two facts, both about this process rather than about the provider: the
|
|
324
|
+
* reading arrived over the network just now, and this command is what wrote it.
|
|
325
|
+
* The second is what stops two refreshers on one machine from both polling.
|
|
326
|
+
*/
|
|
327
|
+
function acquisitionStamp(meters, credential) {
|
|
328
|
+
void credential;
|
|
329
|
+
return withProvenance(meters, ACQUISITION_PROVENANCE).map((meter) => ({ ...meter, writer: "cli" }));
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* What doctor can say about one provider without asking the provider anything.
|
|
333
|
+
*
|
|
334
|
+
* Doctor never touches the network, so this reads three local things and stops:
|
|
335
|
+
* whether a credential exists, what the schedule says, and whether the cache
|
|
336
|
+
* already holds a fresh row for this provider. That is enough to tell apart the
|
|
337
|
+
* four states a person cares about, which are "not set up here", "waiting out a
|
|
338
|
+
* backoff", "set up and current" and "set up and something is wrong".
|
|
339
|
+
*/
|
|
340
|
+
async function acquisitionStatusRow(spec, schedule, readCredential, snapshots, now) {
|
|
341
|
+
const entry = schedule[spec.provider];
|
|
342
|
+
const nextAttemptAt = entry?.nextAttemptAt ?? null;
|
|
343
|
+
/* The same sentence the round itself printed. A provider that says something
|
|
344
|
+
better than the shared vocabulary must say it in both places, or doctor
|
|
345
|
+
quietly contradicts the command a person just ran. */
|
|
346
|
+
const sentence = (outcome) => spec.outcomeSentence?.[outcome] ?? ACQUISITION_OUTCOME_SENTENCE[outcome];
|
|
347
|
+
if (spec.enabled === false) {
|
|
348
|
+
return {
|
|
349
|
+
provider: spec.provider,
|
|
350
|
+
detected: false,
|
|
351
|
+
status: "off",
|
|
352
|
+
reason: spec.disabledReason ?? null,
|
|
353
|
+
nextAttemptAt: null,
|
|
354
|
+
disclosure: spec.disclosure
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const credential = await readCredential(spec.credentialProvider);
|
|
358
|
+
if (!credential.ok) {
|
|
359
|
+
const absent = credential.reason === "absent";
|
|
360
|
+
return {
|
|
361
|
+
provider: spec.provider,
|
|
362
|
+
detected: !absent,
|
|
363
|
+
status: absent ? "not_detected" : "stale",
|
|
364
|
+
reason: CREDENTIAL_FAILURE_SENTENCE[credential.reason],
|
|
365
|
+
nextAttemptAt,
|
|
366
|
+
disclosure: spec.disclosure
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const held = credential.credential;
|
|
370
|
+
const accountId = spec.accountIdFor?.(held) ?? null;
|
|
371
|
+
const disclosure = spec.disclosureFor?.(held) ?? spec.disclosure;
|
|
372
|
+
const identity = {
|
|
373
|
+
provider: spec.provider,
|
|
374
|
+
...(accountId === null ? {} : { accountId }),
|
|
375
|
+
detected: true,
|
|
376
|
+
disclosure
|
|
377
|
+
};
|
|
378
|
+
if (!isProviderDue(entry, now)) {
|
|
379
|
+
return {
|
|
380
|
+
...identity,
|
|
381
|
+
status: "waiting",
|
|
382
|
+
reason: entry === undefined ? null : sentence(entry.outcome),
|
|
383
|
+
nextAttemptAt
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const fresh = snapshots.some((snapshot) => snapshot.provider === spec.provider &&
|
|
387
|
+
freshness(snapshot.observedAt, snapshot.expiresAt, now) === "fresh");
|
|
388
|
+
return {
|
|
389
|
+
...identity,
|
|
390
|
+
status: fresh ? "read" : "stale",
|
|
391
|
+
reason: fresh
|
|
392
|
+
? null
|
|
393
|
+
: entry === undefined
|
|
394
|
+
? "this provider has not been read yet on this machine"
|
|
395
|
+
: sentence(entry.outcome),
|
|
396
|
+
nextAttemptAt
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/** The acquisition block doctor prints under the connector block. */
|
|
400
|
+
async function acquisitionDoctorRows(dependencies, snapshots, now) {
|
|
401
|
+
const providers = await readProvidersConfig(dependencies.stateDirectory);
|
|
402
|
+
const schedule = await readAcquisitionSchedule(dependencies.stateDirectory);
|
|
403
|
+
const readCredential = credentialReader(dependencies);
|
|
404
|
+
const rows = [];
|
|
405
|
+
for (const spec of acquisitionSpecs(providers)) {
|
|
406
|
+
rows.push(acquisitionLine(await acquisitionStatusRow(spec, schedule, readCredential, snapshots, now), snapshots));
|
|
407
|
+
}
|
|
408
|
+
const failedAt = await readRefreshSpawnFailure(dependencies.stateDirectory);
|
|
409
|
+
if (failedAt !== null) {
|
|
410
|
+
rows.push("REFRESH SPAWN FAILED " + failedAt +
|
|
411
|
+
" the background refresh could not be started on this machine");
|
|
412
|
+
}
|
|
413
|
+
return [ACQUISITION_HEADER, ...rows].join(NEWLINE);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Where a person can still see a reading this row could not take.
|
|
417
|
+
*
|
|
418
|
+
* Antigravity and Gemini CLI meter the same Google Code Assist pool. When
|
|
419
|
+
* Google withholds the reading from this client but another tool on the machine
|
|
420
|
+
* has already written one, the honest thing is to point at it rather than leave
|
|
421
|
+
* a bare refusal, so the person knows the number exists and where.
|
|
422
|
+
*/
|
|
423
|
+
function sharedQuotaNote(row, snapshots) {
|
|
424
|
+
if (row.provider !== "ANTIGRAVITY")
|
|
425
|
+
return null;
|
|
426
|
+
return snapshots.some((snapshot) => snapshot.provider === "GEMINI_CLI")
|
|
427
|
+
? "the shared Code Assist quota is shown under gemini_cli"
|
|
428
|
+
: null;
|
|
429
|
+
}
|
|
430
|
+
/** One row of the refresh report, in the space separated grammar doctor uses. */
|
|
431
|
+
function acquisitionLine(row, snapshots = []) {
|
|
432
|
+
const shared = row.status === "read" ? null : sharedQuotaNote(row, snapshots);
|
|
433
|
+
const note = [row.reason ?? row.disclosure ?? "", shared ?? ""]
|
|
434
|
+
.filter((part) => part !== "")
|
|
435
|
+
.join(", ");
|
|
436
|
+
return [
|
|
437
|
+
row.provider.toLowerCase() + (row.accountId === undefined ? "" : "/" + row.accountId),
|
|
438
|
+
row.detected ? "yes" : "no",
|
|
439
|
+
row.status,
|
|
440
|
+
row.nextAttemptAt ?? "NONE",
|
|
441
|
+
note
|
|
442
|
+
].join(" ").trimEnd();
|
|
443
|
+
}
|
|
444
|
+
const ACQUISITION_HEADER = "PROVIDER DETECTED STATUS NEXT NOTE";
|
|
445
|
+
/**
|
|
446
|
+
* Run one round of acquisition and fold what it found into the cache.
|
|
447
|
+
*
|
|
448
|
+
* Three refusals come before any request. A desktop that wrote inside the last
|
|
449
|
+
* interval already owns this machine's refreshing, so this command stands down
|
|
450
|
+
* rather than doubling the traffic a provider sees. A refresh already running
|
|
451
|
+
* holds the lock, so this one exits instead of racing it on the same
|
|
452
|
+
* credentials. And a provider inside its own backoff is skipped by the runner
|
|
453
|
+
* without being asked anything.
|
|
454
|
+
*
|
|
455
|
+
* Only a successful read writes. A refusal, a rate limit or a shape this build
|
|
456
|
+
* did not understand leaves every cached row exactly where it was, to age out
|
|
457
|
+
* through the ordinary freshness rule.
|
|
458
|
+
*/
|
|
459
|
+
async function refreshCommand(dependencies, now) {
|
|
460
|
+
const lock = await acquireRefreshLock(dependencies.stateDirectory);
|
|
461
|
+
if (!lock.ok) {
|
|
462
|
+
return succeed([
|
|
463
|
+
ACQUISITION_HEADER,
|
|
464
|
+
"SKIPPED another refresh is already running on this machine"
|
|
465
|
+
].join(NEWLINE));
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
/*
|
|
469
|
+
* Desktop ownership is decided HERE, under the lock, and nowhere else.
|
|
470
|
+
* Checking it before taking the lock read a cache that a desktop could
|
|
471
|
+
* start writing a millisecond later, and this round would then poll every
|
|
472
|
+
* provider a second time for nothing. One check, on the only side of the
|
|
473
|
+
* lock where the answer cannot change underneath it.
|
|
474
|
+
*/
|
|
475
|
+
const settled = await readSnapshotCache(dependencies.stateDirectory);
|
|
476
|
+
if (desktopHoldsCache(settled.ok ? settled.snapshots : [], now)) {
|
|
477
|
+
return succeed([
|
|
478
|
+
ACQUISITION_HEADER,
|
|
479
|
+
"SKIPPED the desktop app refreshed this cache inside the last interval"
|
|
480
|
+
].join(NEWLINE));
|
|
481
|
+
}
|
|
482
|
+
/* A round that got this far is proof a refresh can start on this machine. */
|
|
483
|
+
await clearRefreshSpawnFailure(dependencies.stateDirectory);
|
|
484
|
+
const providers = await readProvidersConfig(dependencies.stateDirectory);
|
|
485
|
+
const schedule = await readAcquisitionSchedule(dependencies.stateDirectory);
|
|
486
|
+
const result = await runAcquisition(acquisitionSpecs(providers), {
|
|
487
|
+
transport: dependencies.acquisitionTransport,
|
|
488
|
+
now,
|
|
489
|
+
schedule,
|
|
490
|
+
readCredential: credentialReader(dependencies),
|
|
491
|
+
stamp: acquisitionStamp,
|
|
492
|
+
...(dependencies.probeAntigravity === undefined
|
|
493
|
+
? {}
|
|
494
|
+
: { probeAntigravity: dependencies.probeAntigravity }),
|
|
495
|
+
...(dependencies.resolveExecutablePath === undefined
|
|
496
|
+
? {}
|
|
497
|
+
: { resolveExecutablePath: dependencies.resolveExecutablePath })
|
|
498
|
+
});
|
|
499
|
+
/*
|
|
500
|
+
* Ownership is checked before every write, not once at the start. A round
|
|
501
|
+
* can outlive its lock if this machine was suspended mid refresh, and a
|
|
502
|
+
* round that lost its lock must not write over the round that took it.
|
|
503
|
+
*
|
|
504
|
+
* The refresh lock only coordinates other copies of THIS tool. The desktop
|
|
505
|
+
* tray is a separate process that knows nothing about it, so the write
|
|
506
|
+
* itself has to be safe against a desktop row that appeared since this
|
|
507
|
+
* round started reading. `mergeAcquiredSnapshots` decides that per row,
|
|
508
|
+
* inside the cache lock both processes do share, keeping whichever row is
|
|
509
|
+
* newer and leaving a tie with the row already there.
|
|
510
|
+
*/
|
|
511
|
+
for (const report of result.reports) {
|
|
512
|
+
if (!(await lock.stillOwned())) {
|
|
513
|
+
return succeed([
|
|
514
|
+
ACQUISITION_HEADER,
|
|
515
|
+
"SKIPPED this refresh lost its lock before it could write"
|
|
516
|
+
].join(NEWLINE));
|
|
517
|
+
}
|
|
518
|
+
if (!report.ok)
|
|
519
|
+
continue;
|
|
520
|
+
try {
|
|
521
|
+
await mergeAcquiredSnapshots(report, dependencies.stateDirectory);
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
/* One provider's write failing is that provider's problem. The rest of
|
|
525
|
+
the round still has readings worth keeping. */
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (!(await lock.stillOwned())) {
|
|
529
|
+
return succeed([
|
|
530
|
+
ACQUISITION_HEADER,
|
|
531
|
+
"SKIPPED this refresh lost its lock before it could write"
|
|
532
|
+
].join(NEWLINE));
|
|
533
|
+
}
|
|
534
|
+
await writeAcquisitionSchedule(result.schedule, dependencies.stateDirectory);
|
|
535
|
+
const merged = await cachedSnapshots(dependencies.stateDirectory);
|
|
536
|
+
await writeAgentContextSnapshot(merged, dependencies.stateDirectory, now, PROVIDER_CODES).catch(() => undefined);
|
|
537
|
+
/*
|
|
538
|
+
* A sync after a successful refresh, when a session exists. This round
|
|
539
|
+
* already runs off the status line's own path (`startRefreshBehind` spawns
|
|
540
|
+
* it detached and never waits), so nothing here can add to a render.
|
|
541
|
+
*/
|
|
542
|
+
await triggerSyncAfterRefresh(dependencies, now);
|
|
543
|
+
return succeed([
|
|
544
|
+
ACQUISITION_HEADER,
|
|
545
|
+
...result.rows.map((row) => acquisitionLine(row, merged))
|
|
546
|
+
].join(NEWLINE));
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return fail(EXIT_FAILURE, "openlimiter refresh: the refresh did not complete.");
|
|
550
|
+
}
|
|
551
|
+
finally {
|
|
552
|
+
await lock.release();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Start a refresh behind a render, when one is worth starting.
|
|
557
|
+
*
|
|
558
|
+
* Every failure here is swallowed on purpose. This is called from a status line
|
|
559
|
+
* and from a snapshot, and neither of them may fail because a refresh could not
|
|
560
|
+
* be started: the bars this render already has are still true.
|
|
561
|
+
*/
|
|
562
|
+
async function startRefreshBehind(dependencies, snapshots, now) {
|
|
563
|
+
try {
|
|
564
|
+
await spawnDetachedRefresh({
|
|
565
|
+
snapshots,
|
|
566
|
+
now,
|
|
567
|
+
...(dependencies.stateDirectory === undefined
|
|
568
|
+
? {}
|
|
569
|
+
: { stateDirectory: dependencies.stateDirectory }),
|
|
570
|
+
nodeExecutable: dependencies.nodeExecutable,
|
|
571
|
+
openLimiterScript: dependencies.openLimiterScript,
|
|
572
|
+
spawn: dependencies.spawnDetached
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
/* Nothing to report and nothing a person could do about it. */
|
|
577
|
+
}
|
|
578
|
+
}
|
|
128
579
|
function demoSnapshots(now) {
|
|
129
580
|
const raw = [
|
|
130
581
|
...(parseClaudePayload(claudeFixture(now), now) ?? []),
|
|
@@ -139,7 +590,14 @@ function demoSnapshots(now) {
|
|
|
139
590
|
return normalizeMeters(withProvenance(raw, INGEST_PROVENANCE));
|
|
140
591
|
}
|
|
141
592
|
function doctorRows(snapshots, environment, now) {
|
|
142
|
-
|
|
593
|
+
/*
|
|
594
|
+
* PAYLOAD, not DETECTED. This column has always meant "a payload for this
|
|
595
|
+
* connector was pushed into this process", which is a different question from
|
|
596
|
+
* the acquisition table's "a login for this provider exists on this machine",
|
|
597
|
+
* and the two answered differently for the same provider under the same word.
|
|
598
|
+
* One of them had to be renamed and this is the one whose word was wrong.
|
|
599
|
+
*/
|
|
600
|
+
const lines = ["CONNECTOR PAYLOAD FRESHNESS DRIFT"];
|
|
143
601
|
for (const connector of connectors) {
|
|
144
602
|
const provider = connector.id.toUpperCase();
|
|
145
603
|
const states = snapshots
|
|
@@ -160,9 +618,22 @@ function doctorRows(snapshots, environment, now) {
|
|
|
160
618
|
return lines.join("\n");
|
|
161
619
|
}
|
|
162
620
|
const help = [
|
|
621
|
+
"openlimiter",
|
|
622
|
+
"openlimiter setup",
|
|
623
|
+
"openlimiter login [--open]",
|
|
624
|
+
"openlimiter logout",
|
|
625
|
+
"openlimiter whoami",
|
|
626
|
+
"openlimiter sync",
|
|
163
627
|
"openlimiter init",
|
|
164
628
|
"openlimiter snapshot [--refresh]",
|
|
165
|
-
"openlimiter statusline",
|
|
629
|
+
"openlimiter statusline [--host claude|antigravity|grok|codex|shell]",
|
|
630
|
+
"openlimiter terminal [--yes] [--host <id>]",
|
|
631
|
+
"openlimiter terminal status",
|
|
632
|
+
"openlimiter terminal install <host>",
|
|
633
|
+
"openlimiter terminal uninstall <host>",
|
|
634
|
+
"openlimiter terminal show <provider ...>",
|
|
635
|
+
"openlimiter terminal hide <provider ...>",
|
|
636
|
+
"openlimiter refresh",
|
|
166
637
|
"openlimiter hook [--dry-run]",
|
|
167
638
|
"openlimiter hooks install <agent>",
|
|
168
639
|
"openlimiter hooks uninstall <agent>",
|
|
@@ -172,36 +643,60 @@ const help = [
|
|
|
172
643
|
"openlimiter ingest [--provider <id>] [--payload <json>]",
|
|
173
644
|
"openlimiter config get statusline[.<key>]",
|
|
174
645
|
"openlimiter config set statusline.<key> <value>",
|
|
646
|
+
"openlimiter config get providers[.<key>]",
|
|
647
|
+
"openlimiter config set providers.<key> <value>",
|
|
175
648
|
"openlimiter doctor",
|
|
176
649
|
"openlimiter demo",
|
|
177
650
|
"openlimiter export",
|
|
178
|
-
"openlimiter serve [--port <n>] [--host <address>] [--no-qr]",
|
|
179
651
|
"",
|
|
180
652
|
"statusline keys: " + STATUSLINE_KEYS.join(", ") + ".",
|
|
653
|
+
"providers keys: " + PROVIDER_KEYS.join(", ") + ".",
|
|
654
|
+
"terminal hosts: " + TERMINAL_HOST_NAMES.join(", ") + ".",
|
|
181
655
|
"statusline and ingest read JSON from standard input when it is piped in.",
|
|
182
|
-
"
|
|
183
|
-
"
|
|
656
|
+
"openlimiter with no arguments runs setup: sign in, connect, show bars in.",
|
|
657
|
+
"login opens the device code sign in; sync uploads one round to the hub when",
|
|
658
|
+
"a session exists, and refresh triggers it automatically after itself.",
|
|
659
|
+
"refresh reads the logins your provider tools already stored on this machine",
|
|
660
|
+
"and asks each provider for its own usage, at most once every 15 minutes. It",
|
|
661
|
+
"stands down while the desktop app is running. statusline and snapshot start",
|
|
662
|
+
"it in the background when the cache is older than a minute.",
|
|
184
663
|
"Exit codes: 0 success, 1 failure, 2 usage, 3 no bounded quota data."
|
|
185
664
|
].join("\n");
|
|
186
665
|
/**
|
|
187
|
-
* Parse a
|
|
666
|
+
* Parse a status line host's payload from standard input and cache it.
|
|
188
667
|
*
|
|
189
668
|
* This is the path that gives the tool something to meter. It performs no
|
|
190
|
-
* network access at all: it validates the JSON
|
|
191
|
-
*
|
|
192
|
-
*
|
|
669
|
+
* network access at all: it validates the JSON the host already wrote to this
|
|
670
|
+
* process. Every failure returns null so the caller can fall back to the
|
|
671
|
+
* cache instead of breaking the host tool.
|
|
672
|
+
*
|
|
673
|
+
* Which parser runs, and which provenance the reading is stamped with, are
|
|
674
|
+
* decided by the host. Codex names no scripting interface at all (its status
|
|
675
|
+
* line draws only its own built in items) and shell prompts read the cache
|
|
676
|
+
* only, so neither ever hands this anything to parse.
|
|
193
677
|
*/
|
|
194
|
-
async function ingestStandardInput(dependencies, now) {
|
|
678
|
+
async function ingestStandardInput(dependencies, now, host = "claude") {
|
|
679
|
+
if (host === "codex" || host === "shell")
|
|
680
|
+
return null;
|
|
195
681
|
try {
|
|
196
682
|
const document = parseJsonText(await dependencies.readStandardInput());
|
|
197
683
|
if (!document.ok)
|
|
198
684
|
return null;
|
|
199
|
-
const meters =
|
|
685
|
+
const meters = host === "antigravity"
|
|
686
|
+
? parseAntigravityStatuslinePayload(document.value, now)
|
|
687
|
+
: host === "grok"
|
|
688
|
+
? parseGrokStatuslinePayload(document.value, now)
|
|
689
|
+
: parseClaudePayload(document.value, now);
|
|
200
690
|
if (meters === null)
|
|
201
691
|
return null;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
692
|
+
const provenance = host === "antigravity"
|
|
693
|
+
? ANTIGRAVITY_STATUSLINE_PROVENANCE
|
|
694
|
+
: host === "grok"
|
|
695
|
+
? GROK_STATUSLINE_PROVENANCE
|
|
696
|
+
: STATUSLINE_PROVENANCE;
|
|
697
|
+
/* The host wrote this to our standard input in this session. It is a live
|
|
698
|
+
reading, and it says so. */
|
|
699
|
+
const incoming = normalizeMeters(withProvenance(meters, provenance));
|
|
205
700
|
if (incoming.length === 0)
|
|
206
701
|
return null;
|
|
207
702
|
try {
|
|
@@ -246,6 +741,9 @@ async function snapshotCommand(dependencies, argumentsList, now) {
|
|
|
246
741
|
return fail(EXIT_FAILURE, "openlimiter snapshot: quota state could not be read.");
|
|
247
742
|
}
|
|
248
743
|
const snapshots = cached.ok ? cached.snapshots : [];
|
|
744
|
+
if (argumentsList.includes("--refresh")) {
|
|
745
|
+
await startRefreshBehind(dependencies, snapshots, now);
|
|
746
|
+
}
|
|
249
747
|
try {
|
|
250
748
|
await writeAgentContextSnapshot(snapshots, dependencies.stateDirectory, now, PROVIDER_CODES);
|
|
251
749
|
}
|
|
@@ -294,6 +792,7 @@ async function doctorCommand(dependencies, now) {
|
|
|
294
792
|
const dropped = cached.ok ? cached.dropped : 0;
|
|
295
793
|
const lines = [
|
|
296
794
|
doctorRows(snapshots, environment, now),
|
|
795
|
+
await acquisitionDoctorRows(dependencies, snapshots, now),
|
|
297
796
|
"CACHE " + status + " DROPPED " + String(dropped)
|
|
298
797
|
];
|
|
299
798
|
const category = cacheFailureCategory(cached);
|
|
@@ -393,18 +892,33 @@ async function ingestCommand(dependencies, argumentsList, now) {
|
|
|
393
892
|
/**
|
|
394
893
|
* Draw the statusline.
|
|
395
894
|
*
|
|
396
|
-
* Standard input first, so a
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
895
|
+
* Standard input first, so a host's session payload is ingested and drawn in
|
|
896
|
+
* the same call, then the cache. `--host` names which host is asking, which
|
|
897
|
+
* decides both how standard input is parsed and which grammar the bar style
|
|
898
|
+
* draws (a provider's own window carries no tag, every other window does).
|
|
899
|
+
* Absent or unrecognised falls back to `claude`, which is what every
|
|
900
|
+
* installation before this one already assumed. The layout comes from the
|
|
901
|
+
* configuration file and the fallback is the layout's own default, so a
|
|
902
|
+
* machine with no configuration still gets bars.
|
|
400
903
|
*
|
|
401
904
|
* `bars false` hands the whole job back to the adapter that produced the 0.1.0
|
|
402
905
|
* line. That path is byte for byte what it always was, which is the point of
|
|
403
906
|
* keeping it: it is the escape hatch for anything already parsing this output.
|
|
404
907
|
*/
|
|
405
|
-
async function statuslineCommand(dependencies, now) {
|
|
406
|
-
const
|
|
908
|
+
async function statuslineCommand(dependencies, argumentsList, now) {
|
|
909
|
+
const hostFlag = flagValue(argumentsList, "--host");
|
|
910
|
+
const host = hostFlag !== undefined && isStatuslineHost(hostFlag)
|
|
911
|
+
? hostFlag.toLowerCase()
|
|
912
|
+
: "claude";
|
|
913
|
+
const ingested = await ingestStandardInput(dependencies, now, host);
|
|
407
914
|
const snapshots = ingested ?? await cachedSnapshots(dependencies.stateDirectory);
|
|
915
|
+
/*
|
|
916
|
+
* The refresh that keeps the other providers current starts here and is never
|
|
917
|
+
* waited for. This render draws whatever the cache already holds, the child
|
|
918
|
+
* outlives this process, and the next render shows what it found. That is the
|
|
919
|
+
* whole reason a terminal person needs no background service.
|
|
920
|
+
*/
|
|
921
|
+
await startRefreshBehind(dependencies, snapshots, now);
|
|
408
922
|
if (ingested === null) {
|
|
409
923
|
await writeAgentContextSnapshot(snapshots, dependencies.stateDirectory, now, PROVIDER_CODES).catch(() => undefined);
|
|
410
924
|
}
|
|
@@ -417,7 +931,8 @@ async function statuslineCommand(dependencies, now) {
|
|
|
417
931
|
snapshots,
|
|
418
932
|
now,
|
|
419
933
|
config,
|
|
420
|
-
color: statuslineColor(config.color, dependencies.environment, dependencies.colorOutput)
|
|
934
|
+
color: statuslineColor(config.color, dependencies.environment, dependencies.colorOutput),
|
|
935
|
+
host
|
|
421
936
|
}));
|
|
422
937
|
}
|
|
423
938
|
const agentAliases = {
|
|
@@ -580,14 +1095,18 @@ async function explicitStatusCommand(dependencies, argumentsList, now) {
|
|
|
580
1095
|
: succeed(stdout);
|
|
581
1096
|
}
|
|
582
1097
|
/* --------------------------------------------------------------- config */
|
|
583
|
-
/** The
|
|
1098
|
+
/** The two sections this command reads and writes. */
|
|
584
1099
|
const CONFIG_SECTION = "statusline";
|
|
1100
|
+
const PROVIDERS_SECTION = "providers";
|
|
585
1101
|
const configUsage = [
|
|
586
1102
|
"openlimiter config: use one of",
|
|
587
1103
|
" openlimiter config get statusline[.<key>]",
|
|
588
1104
|
" openlimiter config set statusline.<key> <value>",
|
|
589
|
-
"
|
|
590
|
-
|
|
1105
|
+
" openlimiter config get providers[.<key>]",
|
|
1106
|
+
" openlimiter config set providers.<key> <value>",
|
|
1107
|
+
"Statusline keys: " + STATUSLINE_KEYS.join(", ") + ".",
|
|
1108
|
+
"Providers keys: " + PROVIDER_KEYS.join(", ") + "."
|
|
1109
|
+
].join(NEWLINE);
|
|
591
1110
|
/**
|
|
592
1111
|
* Split `statusline.width` into its section and its key.
|
|
593
1112
|
*
|
|
@@ -610,7 +1129,50 @@ function parseConfigPath(target) {
|
|
|
610
1129
|
function configGet(keys, statusline) {
|
|
611
1130
|
return keys
|
|
612
1131
|
.map((key) => CONFIG_SECTION + "." + key + "=" + statuslineValueText(statusline, key))
|
|
613
|
-
.join(
|
|
1132
|
+
.join(NEWLINE);
|
|
1133
|
+
}
|
|
1134
|
+
function providersGet(keys, providers) {
|
|
1135
|
+
return keys
|
|
1136
|
+
.map((key) => PROVIDERS_SECTION + "." + key + "=" + providerValueText(providers, key))
|
|
1137
|
+
.join(NEWLINE);
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Read or change one provider switch.
|
|
1141
|
+
*
|
|
1142
|
+
* Split from the statusline path rather than folded into it because the two
|
|
1143
|
+
* sections mean different things: a statusline key changes what a person sees,
|
|
1144
|
+
* and a providers key changes what this machine asks a provider. Only the
|
|
1145
|
+
* second one has a network consequence, and it deserves its own words.
|
|
1146
|
+
*/
|
|
1147
|
+
async function providersConfigCommand(dependencies, action, key, value) {
|
|
1148
|
+
if (key !== null && !isProviderKey(key)) {
|
|
1149
|
+
return fail(EXIT_USAGE, "openlimiter config: unknown providers key. Known keys: " +
|
|
1150
|
+
PROVIDER_KEYS.join(", ") + ".");
|
|
1151
|
+
}
|
|
1152
|
+
const stored = await readConfig(dependencies.stateDirectory);
|
|
1153
|
+
if (!stored.ok && stored.reason !== "missing") {
|
|
1154
|
+
return fail(EXIT_FAILURE, "openlimiter config: configuration could not be read.");
|
|
1155
|
+
}
|
|
1156
|
+
const config = stored.ok ? stored.config : defaultConfig(dependencies.environment);
|
|
1157
|
+
if (action === "get") {
|
|
1158
|
+
return succeed(providersGet(key === null ? PROVIDER_KEYS : [key], config.providers));
|
|
1159
|
+
}
|
|
1160
|
+
if (key === null) {
|
|
1161
|
+
return fail(EXIT_USAGE, "openlimiter config: set needs a key, as in providers.claude.poll.");
|
|
1162
|
+
}
|
|
1163
|
+
if (value === undefined) {
|
|
1164
|
+
return fail(EXIT_USAGE, "openlimiter config: set needs a value.");
|
|
1165
|
+
}
|
|
1166
|
+
const update = setProviderValue(config.providers, key, value);
|
|
1167
|
+
if (!update.ok)
|
|
1168
|
+
return fail(EXIT_USAGE, "openlimiter config: " + update.message);
|
|
1169
|
+
try {
|
|
1170
|
+
await writeConfig({ ...config, providers: update.providers }, dependencies.stateDirectory);
|
|
1171
|
+
}
|
|
1172
|
+
catch {
|
|
1173
|
+
return fail(EXIT_FAILURE, "openlimiter config: configuration could not be written.");
|
|
1174
|
+
}
|
|
1175
|
+
return succeed(providersGet([key], update.providers));
|
|
614
1176
|
}
|
|
615
1177
|
/**
|
|
616
1178
|
* Read or change the statusline layout.
|
|
@@ -626,8 +1188,14 @@ async function configCommand(dependencies, argumentsList) {
|
|
|
626
1188
|
return fail(EXIT_USAGE, configUsage);
|
|
627
1189
|
}
|
|
628
1190
|
const target = parseConfigPath(argumentsList[2]);
|
|
629
|
-
if (target === null
|
|
630
|
-
return fail(EXIT_USAGE,
|
|
1191
|
+
if (target === null)
|
|
1192
|
+
return fail(EXIT_USAGE, configUsage);
|
|
1193
|
+
if (target.section === PROVIDERS_SECTION) {
|
|
1194
|
+
return await providersConfigCommand(dependencies, action, target.key, argumentsList[3]);
|
|
1195
|
+
}
|
|
1196
|
+
if (target.section !== CONFIG_SECTION) {
|
|
1197
|
+
return fail(EXIT_USAGE, "openlimiter config: only the statusline and providers sections can be " +
|
|
1198
|
+
"read or written.");
|
|
631
1199
|
}
|
|
632
1200
|
if (target.key !== null && !isStatuslineKey(target.key)) {
|
|
633
1201
|
return fail(EXIT_USAGE, "openlimiter config: unknown statusline key. Known keys: " +
|
|
@@ -661,56 +1229,484 @@ async function configCommand(dependencies, argumentsList) {
|
|
|
661
1229
|
}
|
|
662
1230
|
return succeed(configGet([target.key], update.statusline));
|
|
663
1231
|
}
|
|
1232
|
+
const terminalUsage = [
|
|
1233
|
+
"openlimiter terminal [--yes] [--host <id>]",
|
|
1234
|
+
"openlimiter terminal status",
|
|
1235
|
+
"openlimiter terminal install <host>",
|
|
1236
|
+
"openlimiter terminal uninstall <host>",
|
|
1237
|
+
"openlimiter terminal show <provider ...>",
|
|
1238
|
+
"openlimiter terminal hide <provider ...>",
|
|
1239
|
+
"",
|
|
1240
|
+
"hosts: " + TERMINAL_HOST_NAMES.join(", ") + "."
|
|
1241
|
+
].join("\n");
|
|
1242
|
+
/** The provider ids this machine has a login or a key for, right now. */
|
|
1243
|
+
async function detectedProviderIds(dependencies) {
|
|
1244
|
+
const environment = await environmentWithLocalMarkers(dependencies.environment, dependencies.stateDirectory);
|
|
1245
|
+
return connectors
|
|
1246
|
+
.filter((connector) => connector.detect(environment))
|
|
1247
|
+
.map((connector) => connector.id);
|
|
1248
|
+
}
|
|
1249
|
+
function terminalContext(dependencies, detected) {
|
|
1250
|
+
return {
|
|
1251
|
+
homeDirectory: dependencies.homeDirectory,
|
|
1252
|
+
...(dependencies.stateDirectory === undefined
|
|
1253
|
+
? {}
|
|
1254
|
+
: { stateDirectory: dependencies.stateDirectory }),
|
|
1255
|
+
platform: dependencies.platform,
|
|
1256
|
+
detectedProviders: detected,
|
|
1257
|
+
...(dependencies.windowsCredentialRunner === undefined
|
|
1258
|
+
? {}
|
|
1259
|
+
: { shellRunner: dependencies.windowsCredentialRunner })
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Wire, unwire and report on a status line host, and choose what a terminal
|
|
1264
|
+
* shows.
|
|
1265
|
+
*
|
|
1266
|
+
* A bare call is the checklist: every host this build knows, whether it is
|
|
1267
|
+
* already wired, and the one line that wires the rest. It never opens an
|
|
1268
|
+
* interactive prompt, because this command runs as often from a script as
|
|
1269
|
+
* from a person at a keyboard and a prompt neither can answer would hang one
|
|
1270
|
+
* of them. `--yes` is the unattended equivalent of answering yes to every
|
|
1271
|
+
* host in the checklist; `--host <id>` wires exactly one.
|
|
1272
|
+
*/
|
|
1273
|
+
async function terminalCommand(dependencies, argumentsList) {
|
|
1274
|
+
const action = argumentsList[1];
|
|
1275
|
+
const detected = await detectedProviderIds(dependencies);
|
|
1276
|
+
const context = terminalContext(dependencies, detected);
|
|
1277
|
+
const knownHost = (value) => value !== undefined && TERMINAL_HOST_NAMES.includes(value.toLowerCase());
|
|
1278
|
+
if (action === "status") {
|
|
1279
|
+
return succeed(await terminalStatusTable(context));
|
|
1280
|
+
}
|
|
1281
|
+
if (action === "install" || action === "uninstall") {
|
|
1282
|
+
const host = argumentsList[2];
|
|
1283
|
+
if (!knownHost(host)) {
|
|
1284
|
+
return fail(EXIT_USAGE, "openlimiter terminal: " + action + " needs a known host. hosts: " +
|
|
1285
|
+
TERMINAL_HOST_NAMES.join(", ") + ".");
|
|
1286
|
+
}
|
|
1287
|
+
const result = action === "install"
|
|
1288
|
+
? await installHost(host, context)
|
|
1289
|
+
: await uninstallHost(host, context);
|
|
1290
|
+
return result.ok ? succeed(result.message) : fail(EXIT_FAILURE, result.message);
|
|
1291
|
+
}
|
|
1292
|
+
if (action === "show" || action === "hide") {
|
|
1293
|
+
const providerIds = argumentsList.slice(2);
|
|
1294
|
+
if (providerIds.length === 0) {
|
|
1295
|
+
return fail(EXIT_USAGE, "openlimiter terminal: " + action + " needs at least one provider id.");
|
|
1296
|
+
}
|
|
1297
|
+
const result = action === "show"
|
|
1298
|
+
? await terminalShow(providerIds, context)
|
|
1299
|
+
: await terminalHide(providerIds, context);
|
|
1300
|
+
return result.ok ? succeed(result.message) : fail(EXIT_USAGE, result.message);
|
|
1301
|
+
}
|
|
1302
|
+
if (action === undefined || action === "--yes" || action === "--host") {
|
|
1303
|
+
const hostFlag = flagValue(argumentsList, "--host");
|
|
1304
|
+
if (argumentsList.includes("--host") && !knownHost(hostFlag)) {
|
|
1305
|
+
return fail(EXIT_USAGE, "openlimiter terminal: --host needs a known host. hosts: " +
|
|
1306
|
+
TERMINAL_HOST_NAMES.join(", ") + ".");
|
|
1307
|
+
}
|
|
1308
|
+
if (knownHost(hostFlag)) {
|
|
1309
|
+
const result = await installHost(hostFlag, context);
|
|
1310
|
+
return result.ok ? succeed(result.message) : fail(EXIT_FAILURE, result.message);
|
|
1311
|
+
}
|
|
1312
|
+
if (argumentsList.includes("--yes")) {
|
|
1313
|
+
const lines = [];
|
|
1314
|
+
let allOk = true;
|
|
1315
|
+
for (const host of TERMINAL_HOST_NAMES) {
|
|
1316
|
+
const result = await installHost(host, context);
|
|
1317
|
+
if (!result.ok)
|
|
1318
|
+
allOk = false;
|
|
1319
|
+
lines.push(host + ": " + (result.message.split("\n")[0] ?? result.message));
|
|
1320
|
+
}
|
|
1321
|
+
return allOk ? succeed(lines.join("\n")) : fail(EXIT_FAILURE, lines.join("\n"));
|
|
1322
|
+
}
|
|
1323
|
+
const table = await terminalStatusTable(context);
|
|
1324
|
+
return succeed([
|
|
1325
|
+
table,
|
|
1326
|
+
"",
|
|
1327
|
+
"Wire one host: openlimiter terminal install <host>",
|
|
1328
|
+
"Wire every host this build supports: openlimiter terminal --yes",
|
|
1329
|
+
"hosts: " + TERMINAL_HOST_NAMES.join(", ") + "."
|
|
1330
|
+
].join("\n"));
|
|
1331
|
+
}
|
|
1332
|
+
return fail(EXIT_USAGE, terminalUsage);
|
|
1333
|
+
}
|
|
1334
|
+
/* ------------------------------------------------------------------- hub */
|
|
664
1335
|
/**
|
|
665
|
-
*
|
|
1336
|
+
* Sign in to the hub through the device code flow.
|
|
666
1337
|
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
1338
|
+
* The code and the address are shown the moment the hub hands them over,
|
|
1339
|
+
* through `dependencies.emit`, because the poll that follows can take up to
|
|
1340
|
+
* three minutes and a person watching a blank terminal for that long is the
|
|
1341
|
+
* whole flow failing in a way no exit code explains. `--open` additionally
|
|
1342
|
+
* opens a browser tab; the code and the address are printed either way.
|
|
671
1343
|
*/
|
|
672
|
-
async function
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
1344
|
+
async function loginCommand(dependencies, argumentsList) {
|
|
1345
|
+
const outcome = await runDeviceLogin({
|
|
1346
|
+
environment: dependencies.environment,
|
|
1347
|
+
transport: dependencies.hubTransport,
|
|
1348
|
+
sleep: dependencies.sleep,
|
|
1349
|
+
emit: dependencies.emit,
|
|
1350
|
+
...(dependencies.interruptSignal === undefined ? {} : { interruptSignal: dependencies.interruptSignal }),
|
|
1351
|
+
openBrowser: dependencies.openBrowser,
|
|
1352
|
+
open: argumentsList.includes("--open")
|
|
1353
|
+
});
|
|
1354
|
+
if (outcome.kind === "signed_in") {
|
|
1355
|
+
await writeSession(outcome.session, {
|
|
1356
|
+
...(dependencies.stateDirectory === undefined ? {} : { directory: dependencies.stateDirectory }),
|
|
1357
|
+
platform: dependencies.platform,
|
|
1358
|
+
...(dependencies.windowsAclRunner === undefined ? {} : { windowsAclRunner: dependencies.windowsAclRunner })
|
|
1359
|
+
});
|
|
1360
|
+
return succeed("Signed in as " + outcome.session.accountLabel + ".");
|
|
1361
|
+
}
|
|
1362
|
+
if (outcome.kind === "cancelled")
|
|
1363
|
+
return fail(EXIT_FAILURE, "openlimiter login: cancelled.");
|
|
1364
|
+
if (outcome.kind === "denied")
|
|
1365
|
+
return fail(EXIT_FAILURE, "openlimiter login: the sign in was denied.");
|
|
1366
|
+
if (outcome.kind === "expired") {
|
|
1367
|
+
return fail(EXIT_FAILURE, outcome.message ?? "openlimiter login: the code expired before it was approved.");
|
|
676
1368
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
return fail(EXIT_USAGE, "openlimiter serve: the port must be a whole number from 0 to 65535.");
|
|
1369
|
+
if (outcome.kind === "not_configured") {
|
|
1370
|
+
return fail(EXIT_FAILURE, "openlimiter login: the hub is not configured on this build.");
|
|
680
1371
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
1372
|
+
return fail(EXIT_FAILURE, "openlimiter login: " + outcome.message + ".");
|
|
1373
|
+
}
|
|
1374
|
+
/** Forget the stored session. Nothing on the hub is asked to do anything. */
|
|
1375
|
+
async function logoutCommand(dependencies) {
|
|
1376
|
+
await deleteSession(dependencies.stateDirectory);
|
|
1377
|
+
return succeed("Signed out.");
|
|
1378
|
+
}
|
|
1379
|
+
/** Print who is signed in, or say plainly that nobody is. */
|
|
1380
|
+
async function whoamiCommand(dependencies) {
|
|
1381
|
+
const session = await readSession(dependencies.stateDirectory);
|
|
1382
|
+
if (session === null)
|
|
1383
|
+
return fail(EXIT_FAILURE, "openlimiter whoami: not signed in.");
|
|
1384
|
+
return succeed(["Account: " + session.accountLabel, "Device: " + session.deviceId].join(NEWLINE));
|
|
1385
|
+
}
|
|
1386
|
+
/** Persist a session that renewal freshened, using the caller's own options. */
|
|
1387
|
+
async function persistRenewedSession(dependencies, session) {
|
|
1388
|
+
await writeSession(session, {
|
|
1389
|
+
...(dependencies.stateDirectory === undefined ? {} : { directory: dependencies.stateDirectory }),
|
|
1390
|
+
platform: dependencies.platform,
|
|
1391
|
+
...(dependencies.windowsAclRunner === undefined ? {} : { windowsAclRunner: dependencies.windowsAclRunner })
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Build and upload one sync envelope, from the CLI's own explicit command.
|
|
1396
|
+
*
|
|
1397
|
+
* Renewal happens here, before anything is uploaded, exactly as the
|
|
1398
|
+
* deliverable requires: a token within an hour of expiry or already expired
|
|
1399
|
+
* is refreshed first, and a hub side revocation ends the session rather than
|
|
1400
|
+
* being treated as an ordinary network failure.
|
|
1401
|
+
*/
|
|
1402
|
+
async function syncCommand(dependencies, now) {
|
|
1403
|
+
const directory = dependencies.stateDirectory ?? resolveStateDirectory();
|
|
1404
|
+
const session = await readSession(directory);
|
|
1405
|
+
if (session === null) {
|
|
1406
|
+
return fail(EXIT_FAILURE, "openlimiter sync: not signed in, run openlimiter login.");
|
|
684
1407
|
}
|
|
1408
|
+
const renewal = await ensureFreshSession(session, now, dependencies.environment, dependencies.hubTransport);
|
|
1409
|
+
if (renewal.kind === "revoked") {
|
|
1410
|
+
await deleteSession(directory);
|
|
1411
|
+
return fail(EXIT_FAILURE, REVOKED_SENTENCE);
|
|
1412
|
+
}
|
|
1413
|
+
if (renewal.kind === "error") {
|
|
1414
|
+
return fail(EXIT_FAILURE, "openlimiter sync: could not renew the session, try again.");
|
|
1415
|
+
}
|
|
1416
|
+
if (renewal.kind === "renewed")
|
|
1417
|
+
await persistRenewedSession(dependencies, renewal.session);
|
|
1418
|
+
const active = renewal.session;
|
|
1419
|
+
const snapshots = await cachedSnapshots(dependencies.stateDirectory);
|
|
1420
|
+
const result = await runSync({
|
|
1421
|
+
directory,
|
|
1422
|
+
environment: dependencies.environment,
|
|
1423
|
+
transport: dependencies.hubTransport,
|
|
1424
|
+
now,
|
|
1425
|
+
token: active.token,
|
|
1426
|
+
deviceId: active.deviceId,
|
|
1427
|
+
snapshots
|
|
1428
|
+
});
|
|
1429
|
+
if (result.kind === "accepted") {
|
|
1430
|
+
return succeed("Synced: accepted, tier " + (result.tier ?? "free") + ", rows " + String(result.rows) + ".");
|
|
1431
|
+
}
|
|
1432
|
+
if (result.kind === "nothing_to_sync")
|
|
1433
|
+
return succeed("openlimiter sync: nothing to sync yet.");
|
|
1434
|
+
if (result.kind === "revoked") {
|
|
1435
|
+
await deleteSession(directory);
|
|
1436
|
+
return fail(EXIT_FAILURE, REVOKED_SENTENCE);
|
|
1437
|
+
}
|
|
1438
|
+
if (result.kind === "rejected") {
|
|
1439
|
+
return fail(EXIT_FAILURE, "openlimiter sync: the hub rejected the upload.");
|
|
1440
|
+
}
|
|
1441
|
+
return fail(EXIT_FAILURE, "openlimiter sync: the hub could not be reached, try again.");
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Renew and upload behind an acquisition round, when a session exists.
|
|
1445
|
+
*
|
|
1446
|
+
* Called only from `refreshCommand`, which already runs off the status line's
|
|
1447
|
+
* own path (see `startRefreshBehind`): a round started from a status line
|
|
1448
|
+
* spawns this whole command detached and never waits on it, so nothing here
|
|
1449
|
+
* can add to the milliseconds a render takes. Every failure is swallowed: a
|
|
1450
|
+
* refresh that could not sync still refreshed, and a hub that is unconfigured,
|
|
1451
|
+
* unreachable or has revoked this device is not this command's business to
|
|
1452
|
+
* report.
|
|
1453
|
+
*/
|
|
1454
|
+
async function triggerSyncAfterRefresh(dependencies, now) {
|
|
685
1455
|
try {
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1456
|
+
const directory = dependencies.stateDirectory ?? resolveStateDirectory();
|
|
1457
|
+
const session = await readSession(directory);
|
|
1458
|
+
if (session === null)
|
|
1459
|
+
return;
|
|
1460
|
+
const renewal = await ensureFreshSession(session, now, dependencies.environment, dependencies.hubTransport);
|
|
1461
|
+
if (renewal.kind === "revoked") {
|
|
1462
|
+
await deleteSession(directory);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
if (renewal.kind === "error")
|
|
1466
|
+
return;
|
|
1467
|
+
if (renewal.kind === "renewed")
|
|
1468
|
+
await persistRenewedSession(dependencies, renewal.session);
|
|
1469
|
+
const active = renewal.session;
|
|
1470
|
+
const snapshots = await cachedSnapshots(dependencies.stateDirectory);
|
|
1471
|
+
const outcome = await runSync({
|
|
1472
|
+
directory,
|
|
1473
|
+
environment: dependencies.environment,
|
|
1474
|
+
transport: dependencies.hubTransport,
|
|
1475
|
+
now,
|
|
1476
|
+
token: active.token,
|
|
1477
|
+
deviceId: active.deviceId,
|
|
1478
|
+
snapshots
|
|
691
1479
|
});
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
color: dependencies.colorOutput,
|
|
695
|
-
withoutQr: argumentsList.includes("--no-qr")
|
|
696
|
-
}));
|
|
1480
|
+
if (outcome.kind === "revoked")
|
|
1481
|
+
await deleteSession(directory);
|
|
697
1482
|
}
|
|
698
1483
|
catch {
|
|
699
|
-
|
|
1484
|
+
/* A refresh that could not sync still refreshed. */
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/* -------------------------------------------------------------- setup */
|
|
1488
|
+
const SETUP_SIGN_IN_PROMPT = "Sign in to sync your bars to the hub and your phone (free)";
|
|
1489
|
+
async function promptOrSkip(dependencies, question) {
|
|
1490
|
+
const answer = await dependencies.promptChoice?.(question) ?? "";
|
|
1491
|
+
return !answer.trim().toLowerCase().startsWith("s");
|
|
1492
|
+
}
|
|
1493
|
+
/** Step one: sign in, or say plainly why this machine did not. */
|
|
1494
|
+
async function setupSignInStep(dependencies) {
|
|
1495
|
+
const lines = ["1. Sign in", SETUP_SIGN_IN_PROMPT];
|
|
1496
|
+
const existing = await readSession(dependencies.stateDirectory);
|
|
1497
|
+
if (existing !== null) {
|
|
1498
|
+
lines.push("Already signed in as " + existing.accountLabel + ".");
|
|
1499
|
+
return lines;
|
|
1500
|
+
}
|
|
1501
|
+
if (!hubConfigured(dependencies.environment)) {
|
|
1502
|
+
lines.push("Skipped: the hub is not configured on this build.");
|
|
1503
|
+
return lines;
|
|
1504
|
+
}
|
|
1505
|
+
const proceed = await promptOrSkip(dependencies, "Enter to sign in, S to skip: ");
|
|
1506
|
+
if (!proceed) {
|
|
1507
|
+
lines.push("Skipped.");
|
|
1508
|
+
return lines;
|
|
1509
|
+
}
|
|
1510
|
+
const outcome = await runDeviceLogin({
|
|
1511
|
+
environment: dependencies.environment,
|
|
1512
|
+
transport: dependencies.hubTransport,
|
|
1513
|
+
sleep: dependencies.sleep,
|
|
1514
|
+
emit: dependencies.emit,
|
|
1515
|
+
...(dependencies.interruptSignal === undefined ? {} : { interruptSignal: dependencies.interruptSignal }),
|
|
1516
|
+
openBrowser: dependencies.openBrowser,
|
|
1517
|
+
open: false
|
|
1518
|
+
});
|
|
1519
|
+
if (outcome.kind === "signed_in") {
|
|
1520
|
+
await writeSession(outcome.session, {
|
|
1521
|
+
...(dependencies.stateDirectory === undefined ? {} : { directory: dependencies.stateDirectory }),
|
|
1522
|
+
platform: dependencies.platform,
|
|
1523
|
+
...(dependencies.windowsAclRunner === undefined ? {} : { windowsAclRunner: dependencies.windowsAclRunner })
|
|
1524
|
+
});
|
|
1525
|
+
lines.push("Signed in as " + outcome.session.accountLabel + ".");
|
|
700
1526
|
}
|
|
1527
|
+
else if (outcome.kind === "cancelled") {
|
|
1528
|
+
lines.push("Cancelled.");
|
|
1529
|
+
}
|
|
1530
|
+
else {
|
|
1531
|
+
lines.push("Could not sign in this time. Run openlimiter login later.");
|
|
1532
|
+
}
|
|
1533
|
+
return lines;
|
|
1534
|
+
}
|
|
1535
|
+
/** Which acquisition provider reads this agent's own login, when one exists. */
|
|
1536
|
+
const AGENT_CREDENTIAL_PROVIDER = {
|
|
1537
|
+
claude: "CLAUDE",
|
|
1538
|
+
codex: "CODEX",
|
|
1539
|
+
gemini: "GEMINI_CLI",
|
|
1540
|
+
antigravity: "ANTIGRAVITY",
|
|
1541
|
+
grok: "GROK",
|
|
1542
|
+
kimi: "KIMI"
|
|
1543
|
+
};
|
|
1544
|
+
/**
|
|
1545
|
+
* Agents whose device style sign in is untested on this build (decision D5):
|
|
1546
|
+
* shown as "verified on install" instead of a plain install nudge, and never
|
|
1547
|
+
* offered an interactive sign in of their own.
|
|
1548
|
+
*/
|
|
1549
|
+
const UNVERIFIED_DEVICE_LOGIN_AGENTS = new Set(["grok", "kimi"]);
|
|
1550
|
+
const CONNECT_ROW_LABEL = {
|
|
1551
|
+
use_current_login: "use current login",
|
|
1552
|
+
sign_in: "sign in",
|
|
1553
|
+
install: "install",
|
|
1554
|
+
verified_on_install: "verified on install"
|
|
1555
|
+
};
|
|
1556
|
+
async function connectRowState(agent, installed, readCredential) {
|
|
1557
|
+
if (installed === null) {
|
|
1558
|
+
return UNVERIFIED_DEVICE_LOGIN_AGENTS.has(agent) ? "verified_on_install" : "install";
|
|
1559
|
+
}
|
|
1560
|
+
const provider = AGENT_CREDENTIAL_PROVIDER[agent];
|
|
1561
|
+
if (provider === undefined)
|
|
1562
|
+
return "sign_in";
|
|
1563
|
+
const credential = await readCredential(provider);
|
|
1564
|
+
return credential.ok ? "use_current_login" : "sign_in";
|
|
1565
|
+
}
|
|
1566
|
+
function codexFailureSentence(reason) {
|
|
1567
|
+
if (reason === "not_installed")
|
|
1568
|
+
return "not installed";
|
|
1569
|
+
if (reason === "too_old")
|
|
1570
|
+
return "this version is too old, upgrade Codex";
|
|
1571
|
+
if (reason === "storage")
|
|
1572
|
+
return "could not prepare a folder for this sign in";
|
|
1573
|
+
if (reason === "spawn")
|
|
1574
|
+
return "could not be started";
|
|
1575
|
+
return "printed no code to sign in with";
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Offer Codex's device sign in, watch it to an ending, and say which.
|
|
1579
|
+
*
|
|
1580
|
+
* `codex login --device-auth` runs with `CODEX_HOME` pointed at a folder this
|
|
1581
|
+
* product owns under its own state directory, so the person's own Codex
|
|
1582
|
+
* configuration is never touched. Ctrl C cancels the wait and kills the
|
|
1583
|
+
* child; the built in 180 second timeout does the same when nobody finishes.
|
|
1584
|
+
*/
|
|
1585
|
+
async function runCodexDeviceSignIn(dependencies, installed) {
|
|
1586
|
+
if (!codexVersionIsSupported(installed.version)) {
|
|
1587
|
+
return "Codex: " + codexFailureSentence("too_old") + ".";
|
|
1588
|
+
}
|
|
1589
|
+
const stateDirectory = dependencies.stateDirectory ?? resolveStateDirectory();
|
|
1590
|
+
const sessionId = randomUUID().replace(/-/gu, "");
|
|
1591
|
+
const home = managedCodexHome(stateDirectory, sessionId);
|
|
1592
|
+
if (home === null)
|
|
1593
|
+
return "Codex: " + codexFailureSentence("storage") + ".";
|
|
1594
|
+
const runner = dependencies.codexDeviceLoginRunnerFactory(installed.executable);
|
|
1595
|
+
let started;
|
|
1596
|
+
try {
|
|
1597
|
+
started = await startCodexDeviceLogin(runner, home, Date.now());
|
|
1598
|
+
}
|
|
1599
|
+
catch (error) {
|
|
1600
|
+
const reason = error instanceof DeviceLoginError ? error.reason : "spawn";
|
|
1601
|
+
return "Codex: " + codexFailureSentence(reason) + ".";
|
|
1602
|
+
}
|
|
1603
|
+
const { session, start } = started;
|
|
1604
|
+
dependencies.emit("Codex code: " + start.userCode);
|
|
1605
|
+
dependencies.emit("Codex at: " + start.verificationUrl);
|
|
1606
|
+
const deadline = Date.now() + LOGIN_TIMEOUT_MILLISECONDS;
|
|
1607
|
+
for (;;) {
|
|
1608
|
+
if (isAborted(dependencies.interruptSignal)) {
|
|
1609
|
+
session.cancel();
|
|
1610
|
+
return "Codex: sign in cancelled.";
|
|
1611
|
+
}
|
|
1612
|
+
const state = await session.state(Date.now());
|
|
1613
|
+
if (state.kind === "complete")
|
|
1614
|
+
return "Codex: signed in.";
|
|
1615
|
+
if (state.kind === "cancelled")
|
|
1616
|
+
return "Codex: sign in cancelled.";
|
|
1617
|
+
if (state.kind === "timed_out")
|
|
1618
|
+
return "Codex: sign in timed out.";
|
|
1619
|
+
if (state.kind === "failed") {
|
|
1620
|
+
return "Codex: " + codexFailureSentence(state.reason) + ".";
|
|
1621
|
+
}
|
|
1622
|
+
if (Date.now() >= deadline) {
|
|
1623
|
+
session.cancel();
|
|
1624
|
+
return "Codex: sign in timed out.";
|
|
1625
|
+
}
|
|
1626
|
+
await dependencies.sleep(1_000);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
/** Step two: detect installed agent CLIs and their logins, one row each. */
|
|
1630
|
+
async function setupConnectStep(dependencies) {
|
|
1631
|
+
const lines = ["2. Connect"];
|
|
1632
|
+
const environment = await environmentWithLocalMarkers(dependencies.environment, dependencies.stateDirectory);
|
|
1633
|
+
const readCredential = credentialReader(dependencies);
|
|
1634
|
+
let codexInstalled = null;
|
|
1635
|
+
let codexNeedsSignIn = false;
|
|
1636
|
+
for (const agent of CONNECT_AGENT_IDS) {
|
|
1637
|
+
const installed = await detectAgentInstallation(agent, {
|
|
1638
|
+
environment,
|
|
1639
|
+
platform: dependencies.platform
|
|
1640
|
+
});
|
|
1641
|
+
const state = await connectRowState(agent, installed, readCredential);
|
|
1642
|
+
lines.push(agent + ": " + CONNECT_ROW_LABEL[state]);
|
|
1643
|
+
if (agent === "codex") {
|
|
1644
|
+
codexInstalled = installed;
|
|
1645
|
+
codexNeedsSignIn = state === "sign_in";
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
if (codexNeedsSignIn && codexInstalled !== null) {
|
|
1649
|
+
const proceed = await promptOrSkip(dependencies, "Codex has no login yet. Sign in now? Enter to start, S to skip: ");
|
|
1650
|
+
if (proceed)
|
|
1651
|
+
lines.push(await runCodexDeviceSignIn(dependencies, codexInstalled));
|
|
1652
|
+
}
|
|
1653
|
+
else {
|
|
1654
|
+
await promptOrSkip(dependencies, "Enter to accept: ");
|
|
1655
|
+
}
|
|
1656
|
+
return lines;
|
|
1657
|
+
}
|
|
1658
|
+
const CONNECT_AGENT_IDS = [
|
|
1659
|
+
"claude",
|
|
1660
|
+
"codex",
|
|
1661
|
+
"gemini",
|
|
1662
|
+
"antigravity",
|
|
1663
|
+
"grok",
|
|
1664
|
+
"kimi",
|
|
1665
|
+
"opencode"
|
|
1666
|
+
];
|
|
1667
|
+
/** Step three: the existing terminal checklist, unchanged. */
|
|
1668
|
+
async function setupShowBarsStep(dependencies) {
|
|
1669
|
+
const result = await terminalCommand(dependencies, ["terminal"]);
|
|
1670
|
+
return ["3. Show bars in", result.stdout];
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* The three step first run: sign in, connect, show bars in, then the bars
|
|
1674
|
+
* themselves, once.
|
|
1675
|
+
*/
|
|
1676
|
+
async function setupCommand(dependencies, now) {
|
|
1677
|
+
const sections = [];
|
|
1678
|
+
sections.push(...(await setupSignInStep(dependencies)));
|
|
1679
|
+
sections.push(...(await setupConnectStep(dependencies)));
|
|
1680
|
+
sections.push(...(await setupShowBarsStep(dependencies)));
|
|
1681
|
+
const snapshots = await cachedSnapshots(dependencies.stateDirectory);
|
|
1682
|
+
sections.push(renderTable(snapshots, now, dependencies.colorOutput));
|
|
1683
|
+
return succeed(sections.join(NEWLINE));
|
|
701
1684
|
}
|
|
702
1685
|
export async function runCli(argumentsList, overrides = {}) {
|
|
703
1686
|
const dependencies = { ...defaults(), ...overrides };
|
|
704
|
-
const command = argumentsList[0] ?? "
|
|
1687
|
+
const command = argumentsList[0] ?? "setup";
|
|
705
1688
|
const now = dependencies.now();
|
|
706
1689
|
try {
|
|
1690
|
+
if (command === "setup")
|
|
1691
|
+
return await setupCommand(dependencies, now);
|
|
1692
|
+
if (command === "login")
|
|
1693
|
+
return await loginCommand(dependencies, argumentsList);
|
|
1694
|
+
if (command === "logout")
|
|
1695
|
+
return await logoutCommand(dependencies);
|
|
1696
|
+
if (command === "whoami")
|
|
1697
|
+
return await whoamiCommand(dependencies);
|
|
1698
|
+
if (command === "sync")
|
|
1699
|
+
return await syncCommand(dependencies, now);
|
|
707
1700
|
if (command === "init")
|
|
708
1701
|
return await initCommand(dependencies);
|
|
709
1702
|
if (command === "snapshot") {
|
|
710
1703
|
return await snapshotCommand(dependencies, argumentsList, now);
|
|
711
1704
|
}
|
|
712
1705
|
if (command === "statusline") {
|
|
713
|
-
return await statuslineCommand(dependencies, now);
|
|
1706
|
+
return await statuslineCommand(dependencies, argumentsList, now);
|
|
1707
|
+
}
|
|
1708
|
+
if (command === "terminal") {
|
|
1709
|
+
return await terminalCommand(dependencies, argumentsList);
|
|
714
1710
|
}
|
|
715
1711
|
if (command === "config") {
|
|
716
1712
|
return await configCommand(dependencies, argumentsList);
|
|
@@ -723,6 +1719,8 @@ export async function runCli(argumentsList, overrides = {}) {
|
|
|
723
1719
|
if (command === "status") {
|
|
724
1720
|
return await explicitStatusCommand(dependencies, argumentsList, now);
|
|
725
1721
|
}
|
|
1722
|
+
if (command === "refresh")
|
|
1723
|
+
return await refreshCommand(dependencies, now);
|
|
726
1724
|
if (command === "doctor")
|
|
727
1725
|
return await doctorCommand(dependencies, now);
|
|
728
1726
|
if (command === "demo") {
|
|
@@ -733,8 +1731,6 @@ export async function runCli(argumentsList, overrides = {}) {
|
|
|
733
1731
|
if (command === "ingest") {
|
|
734
1732
|
return await ingestCommand(dependencies, argumentsList, now);
|
|
735
1733
|
}
|
|
736
|
-
if (command === "serve")
|
|
737
|
-
return await serveCommand(dependencies, argumentsList);
|
|
738
1734
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
739
1735
|
return succeed(help);
|
|
740
1736
|
}
|
|
@@ -748,6 +1744,10 @@ export async function runCli(argumentsList, overrides = {}) {
|
|
|
748
1744
|
*/
|
|
749
1745
|
if (command === "hook")
|
|
750
1746
|
return { exitCode: EXIT_OK, stdout: "", stderr: "" };
|
|
1747
|
+
/* A detached refresh writes to a discarded stream and has nobody to tell,
|
|
1748
|
+
so it fails quietly rather than leaving an exit code nothing reads. */
|
|
1749
|
+
if (command === "refresh")
|
|
1750
|
+
return { exitCode: EXIT_OK, stdout: "", stderr: "" };
|
|
751
1751
|
if (command === "statusline") {
|
|
752
1752
|
return { exitCode: EXIT_OK, stdout: "OpenLimiter UNKNOWN", stderr: "" };
|
|
753
1753
|
}
|