@runuai/host 0.8.10 → 0.8.12
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/lib/agents/grok.ts +7 -1
- package/lib/engines.ts +433 -72
- package/lib/standard-image.ts +10 -12
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +5 -0
- package/src/ui/types.ts +3 -0
- package/ui/app.js +29 -11
package/lib/agents/grok.ts
CHANGED
|
@@ -148,6 +148,11 @@ export class GrokSession implements AgentSession {
|
|
|
148
148
|
|
|
149
149
|
private buildArgs(prompt: string): string[] {
|
|
150
150
|
const args = ["exec", "-i", "-u", "node"];
|
|
151
|
+
// Host-resident API key (pasted in the engines panel, ADR-015): forward
|
|
152
|
+
// name-only — docker takes the value from the client env, keeping the
|
|
153
|
+
// secret out of argv. Subscription logins need nothing here (task-up
|
|
154
|
+
// copies ~/.grok into the container instead).
|
|
155
|
+
if (process.env.XAI_API_KEY) args.push("-e", "XAI_API_KEY");
|
|
151
156
|
for (const [k, v] of Object.entries(this.agentEnv)) {
|
|
152
157
|
args.push("-e", `${k}=${v}`);
|
|
153
158
|
}
|
|
@@ -249,7 +254,8 @@ register({
|
|
|
249
254
|
supportedModels: () => [...GROK_MODELS],
|
|
250
255
|
defaultModel: GROK_DEFAULT_MODEL,
|
|
251
256
|
supportedEfforts: () => [...GROK_EFFORTS],
|
|
252
|
-
available: () =>
|
|
257
|
+
available: () =>
|
|
258
|
+
existsSync(grokAuthPath()) || Boolean(process.env.XAI_API_KEY),
|
|
253
259
|
create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
|
|
254
260
|
new GrokSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
|
|
255
261
|
});
|
package/lib/engines.ts
CHANGED
|
@@ -3,20 +3,49 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A single descriptor table for the AI coding engines the host can run
|
|
5
5
|
* (Claude, Codex, Kimi, Grok, Cursor) plus the connect/disconnect/status logic
|
|
6
|
-
* the local UI drives
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* the local UI drives (the desktop app renders that same UI). It writes the
|
|
7
|
+
* `.env.local` the RUNNING launchd/npm host reads (UAI_HOME) and sets
|
|
8
|
+
* `process.env` so a connect takes effect WITHOUT a host restart — the
|
|
9
|
+
* adapters' `available()` and task-up's env injection both read `process.env`.
|
|
10
|
+
*
|
|
11
|
+
* CLI resolution: a host supervised by the desktop app (or launchd) inherits a
|
|
12
|
+
* minimal GUI PATH that lacks wherever the engine CLIs live (~/.local/bin,
|
|
13
|
+
* npm/nvm/asdf prefixes, Homebrew), so spawning a bare `claude`/`codex` would
|
|
14
|
+
* ENOENT even though the CLI is installed. We resolve binaries the way the
|
|
15
|
+
* owner's terminal would — well-known install dirs first, then the login
|
|
16
|
+
* shell's PATH (same trick as the desktop's docker.ts) — and spawn with that
|
|
17
|
+
* PATH so `#!/usr/bin/env node` shebangs resolve too.
|
|
11
18
|
*
|
|
12
19
|
* Three auth modes:
|
|
13
|
-
* - token-command (Claude): `claude setup-token`
|
|
14
|
-
*
|
|
20
|
+
* - token-command (Claude): `claude setup-token` shows a token; we run the
|
|
21
|
+
* CLI under a PTY (script(1)), capture the `sk-ant-oat…`
|
|
22
|
+
* value live from the terminal stream and persist it as
|
|
15
23
|
* CLAUDE_CODE_OAUTH_TOKEN. A pasted token is accepted too.
|
|
24
|
+
* The PTY is load-bearing: the CLI's Ink UI buffers ALL
|
|
25
|
+
* output until unmount when stdout isn't a TTY and its
|
|
26
|
+
* final screens wait for a keypress — under plain pipes it
|
|
27
|
+
* emits nothing and never exits (the eternal "Connecting…"
|
|
28
|
+
* of 0.8.11). We resolve as soon as the token appears and
|
|
29
|
+
* kill the CLI rather than wait for it to end on its own.
|
|
16
30
|
* - login-command (Codex/Kimi/Grok): `<cli> login` runs a browser OAuth and
|
|
17
31
|
* writes a config file; success = that file appearing.
|
|
18
32
|
* - api-key (Cursor): no command — persist a pasted CURSOR_API_KEY.
|
|
19
33
|
*
|
|
34
|
+
* Besides its primary mode, an engine can accept a pasted API key as an
|
|
35
|
+
* alternative (`apiKeyHint`/`apiKeyUrl` in the catalog drive the UI):
|
|
36
|
+
* - claude → ANTHROPIC_API_KEY (the adapter already forwards it; a pasted
|
|
37
|
+
* `sk-ant-oat…` still lands in CLAUDE_CODE_OAUTH_TOKEN — classified by
|
|
38
|
+
* prefix, so either credential works in either paste box).
|
|
39
|
+
* - codex → ~/.codex/auth.json `{"OPENAI_API_KEY": …}`, the same plaintext
|
|
40
|
+
* file `codex login --with-api-key` writes. Written directly (not via the
|
|
41
|
+
* CLI): task-up docker-cp's that FILE into containers, so a keychain-backed
|
|
42
|
+
* CLI login would break task auth, and this works with no host CLI at all.
|
|
43
|
+
* - grok → XAI_API_KEY (documented pay-as-you-go fallback of the grok CLI;
|
|
44
|
+
* the adapter forwards it into task containers).
|
|
45
|
+
* - kimi → none: Kimi Code reads keys only from its config.toml via the
|
|
46
|
+
* interactive `/login`, never from the environment — so we don't offer a
|
|
47
|
+
* paste box we can't honor.
|
|
48
|
+
*
|
|
20
49
|
* Detection mirrors each adapter's `available()`: env/`.env.local` for
|
|
21
50
|
* claude/cursor, a config file under the owner home for codex/kimi/grok.
|
|
22
51
|
*
|
|
@@ -25,7 +54,11 @@
|
|
|
25
54
|
* the real filesystem or spawning anything.
|
|
26
55
|
*/
|
|
27
56
|
|
|
28
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
execFile as nodeExecFile,
|
|
59
|
+
spawn as nodeSpawn,
|
|
60
|
+
type ChildProcess,
|
|
61
|
+
} from "node:child_process";
|
|
29
62
|
import {
|
|
30
63
|
chmodSync,
|
|
31
64
|
existsSync,
|
|
@@ -51,6 +84,10 @@ export interface EngineCatalogEntry {
|
|
|
51
84
|
notes: string | null;
|
|
52
85
|
/** Where to mint an API key (api-key mode only). */
|
|
53
86
|
getKeyUrl: string | null;
|
|
87
|
+
/** Placeholder for the pasted-API-key alternative; null = not supported. */
|
|
88
|
+
apiKeyHint: string | null;
|
|
89
|
+
/** Where to mint a key for the pasted-API-key alternative. */
|
|
90
|
+
apiKeyUrl: string | null;
|
|
54
91
|
}
|
|
55
92
|
|
|
56
93
|
interface EngineDescriptor {
|
|
@@ -58,6 +95,9 @@ interface EngineDescriptor {
|
|
|
58
95
|
authMode: EngineAuthMode;
|
|
59
96
|
notes: string;
|
|
60
97
|
getKeyUrl?: string;
|
|
98
|
+
/** Pasted-API-key alternative (absent = login/token only, e.g. kimi). */
|
|
99
|
+
apiKeyHint?: string;
|
|
100
|
+
apiKeyUrl?: string;
|
|
61
101
|
}
|
|
62
102
|
|
|
63
103
|
/** Static descriptor table — the single source of truth for engine metadata. */
|
|
@@ -67,11 +107,15 @@ const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
|
|
|
67
107
|
authMode: "token-command",
|
|
68
108
|
notes:
|
|
69
109
|
"Opens your browser to authorize Claude, then captures the token automatically.",
|
|
110
|
+
apiKeyHint: "sk-ant-…",
|
|
111
|
+
apiKeyUrl: "https://console.anthropic.com/settings/keys",
|
|
70
112
|
},
|
|
71
113
|
codex: {
|
|
72
114
|
label: "Codex",
|
|
73
115
|
authMode: "login-command",
|
|
74
116
|
notes: "Opens your browser to sign in to your OpenAI Codex account.",
|
|
117
|
+
apiKeyHint: "sk-…",
|
|
118
|
+
apiKeyUrl: "https://platform.openai.com/api-keys",
|
|
75
119
|
},
|
|
76
120
|
kimi: {
|
|
77
121
|
label: "Kimi Code",
|
|
@@ -84,6 +128,8 @@ const DESCRIPTORS: Record<EngineKind, EngineDescriptor> = {
|
|
|
84
128
|
authMode: "login-command",
|
|
85
129
|
notes:
|
|
86
130
|
"Sign in with your xAI Grok subscription. Activates after the next image rebuild.",
|
|
131
|
+
apiKeyHint: "xai-…",
|
|
132
|
+
apiKeyUrl: "https://console.x.ai",
|
|
87
133
|
},
|
|
88
134
|
cursor: {
|
|
89
135
|
label: "Cursor",
|
|
@@ -101,10 +147,14 @@ const ORDER: EngineKind[] = ["claude", "codex", "kimi", "grok", "cursor"];
|
|
|
101
147
|
// ---------------------------------------------------------------------------
|
|
102
148
|
|
|
103
149
|
/** How a login/token command is spawned. Tests provide a fake. */
|
|
104
|
-
export type EngineSpawn = (
|
|
150
|
+
export type EngineSpawn = (
|
|
151
|
+
command: string,
|
|
152
|
+
args: string[],
|
|
153
|
+
env: NodeJS.ProcessEnv,
|
|
154
|
+
) => ChildProcess;
|
|
105
155
|
|
|
106
156
|
export interface EngineSeams {
|
|
107
|
-
/** Spawn a CLI, piping stdout/stderr. */
|
|
157
|
+
/** Spawn a CLI, piping stdin/stdout/stderr. */
|
|
108
158
|
spawn: EngineSpawn;
|
|
109
159
|
/** Absolute path to the `.env.local` the running host reads (UAI_HOME). */
|
|
110
160
|
envLocalPath: () => string;
|
|
@@ -112,18 +162,100 @@ export interface EngineSeams {
|
|
|
112
162
|
ownerHome: () => string;
|
|
113
163
|
/** The live process env (adapters + task-up read creds from here). */
|
|
114
164
|
procEnv: NodeJS.ProcessEnv;
|
|
165
|
+
/** PATH as the owner's login shell sees it (null when unprobeable). */
|
|
166
|
+
loginShellPath: () => Promise<string | null>;
|
|
167
|
+
/** System-wide bin dirs probed for engine CLIs (tests pin to []). */
|
|
168
|
+
systemBinDirs: string[];
|
|
169
|
+
/**
|
|
170
|
+
* Wrap a CLI invocation so the child gets a real PTY (script(1)), or null
|
|
171
|
+
* to spawn directly. Tests pin null; token-command needs the wrap (see
|
|
172
|
+
* defaultPtyWrap for why).
|
|
173
|
+
*/
|
|
174
|
+
ptyWrap: ((bin: string, args: string[]) => PtyCommand | null) | null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A wrapped invocation: what to actually spawn to run a CLI under a PTY. */
|
|
178
|
+
export interface PtyCommand {
|
|
179
|
+
command: string;
|
|
180
|
+
args: string[];
|
|
115
181
|
}
|
|
116
182
|
|
|
117
183
|
function defaultSeams(): EngineSeams {
|
|
118
184
|
return {
|
|
119
|
-
spawn: (command, args) =>
|
|
120
|
-
nodeSpawn(command, args, {
|
|
185
|
+
spawn: (command, args, spawnEnv) =>
|
|
186
|
+
nodeSpawn(command, args, {
|
|
187
|
+
// stdin stays open (pipe): the token flow answers "Press Enter" gates.
|
|
188
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
189
|
+
env: spawnEnv,
|
|
190
|
+
}),
|
|
121
191
|
envLocalPath: () => join(env.uaiHome, ".env.local"),
|
|
122
192
|
ownerHome: () => process.env.UAI_OWNER_HOME?.trim() || homedir(),
|
|
123
193
|
procEnv: process.env,
|
|
194
|
+
loginShellPath: probeLoginShellPath,
|
|
195
|
+
systemBinDirs: ["/opt/homebrew/bin", "/usr/local/bin"],
|
|
196
|
+
ptyWrap: defaultPtyWrap,
|
|
124
197
|
};
|
|
125
198
|
}
|
|
126
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Lend a CLI a real PTY via script(1) — no native deps, ships with macOS
|
|
202
|
+
* (BSD) and Linux (util-linux; argument conventions differ, hence the
|
|
203
|
+
* branch). Ink-based CLIs (claude) render nothing under plain pipes: Ink
|
|
204
|
+
* detects a non-TTY stdout and buffers every frame until unmount, and the
|
|
205
|
+
* interactive screens can't complete without a terminal — so the child sat
|
|
206
|
+
* silent forever. The inner `stty cols 400` matters too: at the default 80
|
|
207
|
+
* columns the token line WRAPS and a wrapped token extracts truncated.
|
|
208
|
+
*/
|
|
209
|
+
function defaultPtyWrap(bin: string, args: string[]): PtyCommand | null {
|
|
210
|
+
if (!existsSync("/usr/bin/script")) return null;
|
|
211
|
+
const stty = "stty cols 400 rows 100 2>/dev/null";
|
|
212
|
+
if (process.platform === "darwin") {
|
|
213
|
+
// BSD script: `script -q /dev/null command [args…]` — command is exec'd
|
|
214
|
+
// as an argv (no shell), so route through sh only to set the width.
|
|
215
|
+
return {
|
|
216
|
+
command: "/usr/bin/script",
|
|
217
|
+
args: ["-q", "/dev/null", "/bin/sh", "-c", `${stty}; exec "$0" "$@"`, bin, ...args],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (process.platform === "linux") {
|
|
221
|
+
// util-linux script: the command is a single shell string (-c).
|
|
222
|
+
const quoted = [bin, ...args]
|
|
223
|
+
.map((a) => `'${a.replace(/'/g, `'\\''`)}'`)
|
|
224
|
+
.join(" ");
|
|
225
|
+
return {
|
|
226
|
+
command: "/usr/bin/script",
|
|
227
|
+
args: ["-qec", `${stty}; exec ${quoted}`, "/dev/null"],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The PATH the owner's login shell exports. Runs `/usr/bin/env` (not
|
|
235
|
+
* `echo $PATH`) so the value is the real colon-joined exported PATH under any
|
|
236
|
+
* shell — fish's `$PATH` is a list and would echo space-joined — and takes the
|
|
237
|
+
* LAST `PATH=` line so profile echo noise can't spoof it. Null on any failure;
|
|
238
|
+
* callers fall back to the inherited PATH.
|
|
239
|
+
*/
|
|
240
|
+
function probeLoginShellPath(): Promise<string | null> {
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
const shell = process.env.SHELL?.trim() || "/bin/zsh";
|
|
243
|
+
nodeExecFile(
|
|
244
|
+
shell,
|
|
245
|
+
["-lc", "/usr/bin/env"],
|
|
246
|
+
{ timeout: 8_000 },
|
|
247
|
+
(err, stdout) => {
|
|
248
|
+
if (err) return resolve(null);
|
|
249
|
+
const lines = String(stdout)
|
|
250
|
+
.split(/\r?\n/)
|
|
251
|
+
.filter((l) => l.startsWith("PATH="));
|
|
252
|
+
const last = lines[lines.length - 1];
|
|
253
|
+
resolve(last ? last.slice("PATH=".length).trim() || null : null);
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
127
259
|
function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
|
|
128
260
|
return { ...defaultSeams(), ...seams };
|
|
129
261
|
}
|
|
@@ -132,7 +264,7 @@ function withDefaults(seams: Partial<EngineSeams>): EngineSeams {
|
|
|
132
264
|
// Public catalog / status.
|
|
133
265
|
// ---------------------------------------------------------------------------
|
|
134
266
|
|
|
135
|
-
/** The static engine catalog (kind, label, authMode, notes,
|
|
267
|
+
/** The static engine catalog (kind, label, authMode, notes, key URLs). */
|
|
136
268
|
export function engineCatalog(): EngineCatalogEntry[] {
|
|
137
269
|
return ORDER.map((kind) => {
|
|
138
270
|
const d = DESCRIPTORS[kind];
|
|
@@ -142,6 +274,8 @@ export function engineCatalog(): EngineCatalogEntry[] {
|
|
|
142
274
|
authMode: d.authMode,
|
|
143
275
|
notes: d.notes,
|
|
144
276
|
getKeyUrl: d.getKeyUrl ?? null,
|
|
277
|
+
apiKeyHint: d.apiKeyHint ?? null,
|
|
278
|
+
apiKeyUrl: d.apiKeyUrl ?? null,
|
|
145
279
|
};
|
|
146
280
|
});
|
|
147
281
|
}
|
|
@@ -183,9 +317,9 @@ export function isEngineKind(value: unknown): value is EngineKind {
|
|
|
183
317
|
// ---------------------------------------------------------------------------
|
|
184
318
|
|
|
185
319
|
export interface ConnectOptions {
|
|
186
|
-
/**
|
|
320
|
+
/** A pasted API key — primary for Cursor, alternative for claude/codex/grok. */
|
|
187
321
|
apiKey?: string;
|
|
188
|
-
/** token-command manual fallback: a pasted token (Claude). */
|
|
322
|
+
/** token-command manual fallback: a pasted token or API key (Claude). */
|
|
189
323
|
pastedToken?: string;
|
|
190
324
|
}
|
|
191
325
|
|
|
@@ -207,21 +341,14 @@ export async function connectEngine(
|
|
|
207
341
|
const s = withDefaults(seams);
|
|
208
342
|
const d = DESCRIPTORS[kind];
|
|
209
343
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return {
|
|
215
|
-
ok: false,
|
|
216
|
-
message: "That doesn't look like an API key. Paste just the value.",
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
upsertEnvLocal("CURSOR_API_KEY", key, s);
|
|
220
|
-
return { ok: true, message: `${d.label} connected.` };
|
|
344
|
+
// A pasted API key connects without spawning anything — the only flow for
|
|
345
|
+
// api-key engines (Cursor), an alternative for the others.
|
|
346
|
+
if (d.authMode === "api-key" || opts.apiKey !== undefined) {
|
|
347
|
+
return saveApiKey(kind, d, opts.apiKey ?? "", s);
|
|
221
348
|
}
|
|
222
349
|
|
|
223
350
|
if (d.authMode === "token-command") {
|
|
224
|
-
// Manual paste fallback — accept a token without spawning
|
|
351
|
+
// Manual paste fallback — accept a token (or API key) without spawning.
|
|
225
352
|
if (opts.pastedToken !== undefined) {
|
|
226
353
|
const token = opts.pastedToken.trim();
|
|
227
354
|
if (!token) return { ok: false, message: "Paste a token." };
|
|
@@ -231,7 +358,15 @@ export async function connectEngine(
|
|
|
231
358
|
message: "That doesn't look like a token. Paste just the value.",
|
|
232
359
|
};
|
|
233
360
|
}
|
|
234
|
-
|
|
361
|
+
// One paste box serves both credential kinds — classify by prefix so an
|
|
362
|
+
// `sk-ant-api…` platform key doesn't get stored as an OAuth token.
|
|
363
|
+
upsertEnvLocal(
|
|
364
|
+
token.startsWith("sk-ant-api")
|
|
365
|
+
? "ANTHROPIC_API_KEY"
|
|
366
|
+
: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
367
|
+
token,
|
|
368
|
+
s,
|
|
369
|
+
);
|
|
235
370
|
return { ok: true, message: `${d.label} connected.` };
|
|
236
371
|
}
|
|
237
372
|
return runTokenCommand(kind, d.label, onLog, s);
|
|
@@ -240,6 +375,70 @@ export async function connectEngine(
|
|
|
240
375
|
return runLoginCommand(kind, d.label, onLog, s);
|
|
241
376
|
}
|
|
242
377
|
|
|
378
|
+
/** Persist a pasted API key where the engine's adapter actually reads it. */
|
|
379
|
+
function saveApiKey(
|
|
380
|
+
kind: EngineKind,
|
|
381
|
+
d: EngineDescriptor,
|
|
382
|
+
raw: string,
|
|
383
|
+
s: EngineSeams,
|
|
384
|
+
): ConnectResult {
|
|
385
|
+
const key = raw.trim();
|
|
386
|
+
if (!key) return { ok: false, message: `Paste your ${d.label} API key.` };
|
|
387
|
+
if (/\s/.test(key) || key.length < 8) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
message: "That doesn't look like an API key. Paste just the value.",
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
switch (kind) {
|
|
394
|
+
case "cursor":
|
|
395
|
+
upsertEnvLocal("CURSOR_API_KEY", key, s);
|
|
396
|
+
break;
|
|
397
|
+
case "claude":
|
|
398
|
+
// Accept either credential kind here too (see the paste fallback above).
|
|
399
|
+
upsertEnvLocal(
|
|
400
|
+
key.startsWith("sk-ant-oat") ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY",
|
|
401
|
+
key,
|
|
402
|
+
s,
|
|
403
|
+
);
|
|
404
|
+
break;
|
|
405
|
+
case "grok":
|
|
406
|
+
// The grok CLI's documented pay-as-you-go fallback; the adapter forwards
|
|
407
|
+
// it into task containers.
|
|
408
|
+
upsertEnvLocal("XAI_API_KEY", key, s);
|
|
409
|
+
break;
|
|
410
|
+
case "codex":
|
|
411
|
+
writeCodexAuthJson(key, s);
|
|
412
|
+
break;
|
|
413
|
+
case "kimi":
|
|
414
|
+
return {
|
|
415
|
+
ok: false,
|
|
416
|
+
message:
|
|
417
|
+
"Kimi Code authenticates via subscription sign-in — it doesn't read API keys from the environment.",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return { ok: true, message: `${d.label} connected.` };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Write `~/.codex/auth.json` the way `codex login --with-api-key` does. We
|
|
425
|
+
* write the plaintext file directly (not via the CLI) because task-up
|
|
426
|
+
* docker-cp's this exact FILE into containers — a keychain-backed CLI login
|
|
427
|
+
* would leave nothing to copy — and it must work without a host codex CLI.
|
|
428
|
+
*/
|
|
429
|
+
function writeCodexAuthJson(key: string, s: EngineSeams): void {
|
|
430
|
+
const file = join(s.ownerHome(), ".codex", "auth.json");
|
|
431
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
432
|
+
writeFileSync(file, `${JSON.stringify({ OPENAI_API_KEY: key }, null, 2)}\n`, {
|
|
433
|
+
mode: 0o600,
|
|
434
|
+
});
|
|
435
|
+
try {
|
|
436
|
+
chmodSync(file, 0o600);
|
|
437
|
+
} catch {
|
|
438
|
+
/* best effort */
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
243
442
|
/** Disconnect an engine: forget its credential (env line and/or config file). */
|
|
244
443
|
export function disconnectEngine(
|
|
245
444
|
kind: EngineKind,
|
|
@@ -256,6 +455,8 @@ export function disconnectEngine(
|
|
|
256
455
|
removeEnvLocal("ANTHROPIC_AUTH_TOKEN", s);
|
|
257
456
|
return;
|
|
258
457
|
}
|
|
458
|
+
// grok can be connected via login (auth.json) OR a pasted key — drop both.
|
|
459
|
+
if (kind === "grok") removeEnvLocal("XAI_API_KEY", s);
|
|
259
460
|
// login-command engines: remove the config file the adapter detects.
|
|
260
461
|
try {
|
|
261
462
|
rmSync(configPath(kind, s), { force: true });
|
|
@@ -265,7 +466,32 @@ export function disconnectEngine(
|
|
|
265
466
|
}
|
|
266
467
|
|
|
267
468
|
/**
|
|
268
|
-
*
|
|
469
|
+
* Flatten a PTY byte stream into plain text lines. Strips OSC sequences
|
|
470
|
+
* (incl. OSC-8 hyperlink wrappers, keeping their visible text), CSI
|
|
471
|
+
* sequences (cursor-column moves become a space — Ink positions words with
|
|
472
|
+
* `ESC[<n>G` instead of writing spaces), stray ESC singles, and normalizes
|
|
473
|
+
* `\r` to `\n`. Identity on already-plain text apart from whitespace runs.
|
|
474
|
+
*/
|
|
475
|
+
export function sanitizeTerminalOutput(raw: string): string {
|
|
476
|
+
return raw
|
|
477
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC …BEL / …ST
|
|
478
|
+
.replace(/\x1b\[[0-9;:?<=>]*[!-/]*[@-~]/g, " ") // CSI
|
|
479
|
+
.replace(/\x1b[()][0-9A-B]/g, "") // charset selects
|
|
480
|
+
.replace(/\x1b[78=>DEHM]/g, "") // save/restore cursor etc.
|
|
481
|
+
.replace(/\r\n?/g, "\n")
|
|
482
|
+
.replace(/[^\S\n]+/g, " ");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Lines worth showing a human: non-blank, not spinner or logo-art glyphs. */
|
|
486
|
+
function significantLines(text: string): string[] {
|
|
487
|
+
return text
|
|
488
|
+
.split("\n")
|
|
489
|
+
.map((l) => l.trim())
|
|
490
|
+
.filter((l) => l.length > 1 && !/^[·✢✳✶✻✽*+.…|/\\\-░▒▓█▄▀\s]+$/u.test(l));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Extract a Claude OAuth token from `claude setup-token` output. The CLI shows
|
|
269
495
|
* the token (an `sk-ant-oat…` value) on its own line near the end; scan from the
|
|
270
496
|
* bottom for it, falling back to a lone long token-charset line.
|
|
271
497
|
*/
|
|
@@ -299,9 +525,10 @@ function detect(kind: EngineKind, s: EngineSeams): boolean {
|
|
|
299
525
|
);
|
|
300
526
|
case "cursor":
|
|
301
527
|
return envOrFileHas("CURSOR_API_KEY", s);
|
|
528
|
+
case "grok":
|
|
529
|
+
return existsSync(configPath(kind, s)) || envOrFileHas("XAI_API_KEY", s);
|
|
302
530
|
case "codex":
|
|
303
531
|
case "kimi":
|
|
304
|
-
case "grok":
|
|
305
532
|
return existsSync(configPath(kind, s));
|
|
306
533
|
}
|
|
307
534
|
}
|
|
@@ -327,48 +554,174 @@ function configPath(kind: EngineKind, s: EngineSeams): string {
|
|
|
327
554
|
return join(home, ".grok", "auth.json");
|
|
328
555
|
}
|
|
329
556
|
|
|
330
|
-
/**
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
557
|
+
/**
|
|
558
|
+
* Well-known install locations for an engine CLI — the dirs a GUI-launched
|
|
559
|
+
* host's minimal PATH misses. Home-relative entries resolve against the OWNER
|
|
560
|
+
* home (seam-injectable). The binary name equals the engine kind for all four
|
|
561
|
+
* CLI engines.
|
|
562
|
+
*/
|
|
563
|
+
function candidateBins(kind: EngineKind, s: EngineSeams): string[] {
|
|
564
|
+
const home = s.ownerHome();
|
|
565
|
+
const common = [join(home, ".local", "bin"), ...s.systemBinDirs];
|
|
566
|
+
switch (kind) {
|
|
567
|
+
case "claude":
|
|
568
|
+
// Native installer symlinks ~/.local/bin/claude (preferred); the older
|
|
569
|
+
// "local install" migration keeps a wrapper at ~/.claude/local/claude.
|
|
570
|
+
return [join(home, ".local", "bin"), join(home, ".claude", "local"), ...s.systemBinDirs].map(
|
|
571
|
+
(d) => join(d, "claude"),
|
|
572
|
+
);
|
|
573
|
+
case "codex":
|
|
574
|
+
return common.map((d) => join(d, "codex"));
|
|
575
|
+
case "kimi":
|
|
576
|
+
return [join(home, ".kimi-code", "bin"), ...common].map((d) =>
|
|
577
|
+
join(d, "kimi"),
|
|
578
|
+
);
|
|
579
|
+
case "grok":
|
|
580
|
+
return [join(home, ".grok", "bin"), ...common].map((d) => join(d, "grok"));
|
|
581
|
+
case "cursor":
|
|
582
|
+
return []; // api-key mode — never spawned
|
|
335
583
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
interface ResolvedBin {
|
|
587
|
+
/** Command to spawn — absolute when found, else the bare name. */
|
|
588
|
+
bin: string;
|
|
589
|
+
/** PATH for the child, augmented so nested lookups (node, git) resolve. */
|
|
590
|
+
path: string;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Find the engine CLI the way the owner's terminal would: well-known install
|
|
595
|
+
* dirs first (fast, no subprocess), then the login shell's PATH (sources the
|
|
596
|
+
* owner's profile — covers nvm/asdf/npm prefixes), else the bare name against
|
|
597
|
+
* the inherited PATH so a genuinely missing CLI still surfaces the friendly
|
|
598
|
+
* ENOENT "install it first" message.
|
|
599
|
+
*/
|
|
600
|
+
async function resolveEngineBin(
|
|
601
|
+
kind: EngineKind,
|
|
602
|
+
s: EngineSeams,
|
|
603
|
+
): Promise<ResolvedBin> {
|
|
604
|
+
const inherited = s.procEnv.PATH ?? "";
|
|
605
|
+
for (const file of candidateBins(kind, s)) {
|
|
606
|
+
if (existsSync(file)) {
|
|
607
|
+
return { bin: file, path: prependPath([dirname(file)], inherited) };
|
|
608
|
+
}
|
|
339
609
|
}
|
|
340
|
-
|
|
341
|
-
|
|
610
|
+
const login = await s.loginShellPath();
|
|
611
|
+
if (login) {
|
|
612
|
+
const dirs = login.split(":").filter(Boolean);
|
|
613
|
+
const dir = dirs.find((d) => existsSync(join(d, kind)));
|
|
614
|
+
// Found or not, hand the login PATH to the child — it is the environment
|
|
615
|
+
// where the CLI (and whatever it shells out to) is known to work.
|
|
616
|
+
return {
|
|
617
|
+
bin: dir ? join(dir, kind) : kind,
|
|
618
|
+
path: prependPath(dirs, inherited),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
return { bin: kind, path: inherited };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Prepend dirs to a colon-joined PATH, deduped, order-preserving. */
|
|
625
|
+
function prependPath(prepend: string[], current: string): string {
|
|
626
|
+
const seen = new Set<string>();
|
|
627
|
+
return [...prepend, ...current.split(":")]
|
|
628
|
+
.filter((p) => p.length > 0 && !seen.has(p) && (seen.add(p), true))
|
|
629
|
+
.join(":");
|
|
342
630
|
}
|
|
343
631
|
|
|
344
|
-
|
|
632
|
+
/** Resolve the CLI and spawn it (optionally under a PTY) with the augmented PATH. */
|
|
633
|
+
async function spawnEngineCli(
|
|
634
|
+
kind: EngineKind,
|
|
635
|
+
args: string[],
|
|
636
|
+
s: EngineSeams,
|
|
637
|
+
pty = false,
|
|
638
|
+
): Promise<ChildProcess> {
|
|
639
|
+
const { bin, path } = await resolveEngineBin(kind, s);
|
|
640
|
+
const wrapped = pty && s.ptyWrap ? s.ptyWrap(bin, args) : null;
|
|
641
|
+
const spawnEnv: NodeJS.ProcessEnv = { ...s.procEnv, PATH: path };
|
|
642
|
+
// A GUI-supervised host has no TERM; give the PTY child a sane one.
|
|
643
|
+
if (wrapped && !spawnEnv.TERM) spawnEnv.TERM = "xterm-256color";
|
|
644
|
+
return wrapped
|
|
645
|
+
? s.spawn(wrapped.command, wrapped.args, spawnEnv)
|
|
646
|
+
: s.spawn(bin, args, spawnEnv);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Browser authorization is human-paced — give it real time before bailing. */
|
|
650
|
+
const TOKEN_FLOW_TIMEOUT_MS = 10 * 60_000;
|
|
651
|
+
|
|
652
|
+
const TOKEN_FLOW_FALLBACK =
|
|
653
|
+
"Try again, or run `claude setup-token` in your terminal and paste the token here.";
|
|
654
|
+
|
|
655
|
+
async function runTokenCommand(
|
|
345
656
|
kind: EngineKind,
|
|
346
657
|
label: string,
|
|
347
658
|
onLog: (line: string) => void,
|
|
348
659
|
s: EngineSeams,
|
|
349
660
|
): Promise<ConnectResult> {
|
|
661
|
+
let child: ChildProcess;
|
|
662
|
+
try {
|
|
663
|
+
child = await spawnEngineCli(kind, ["setup-token"], s, true);
|
|
664
|
+
} catch (err) {
|
|
665
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
666
|
+
}
|
|
350
667
|
return new Promise((resolve) => {
|
|
351
668
|
let settled = false;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
669
|
+
let timer: NodeJS.Timeout | undefined;
|
|
670
|
+
const done = (r: ConnectResult, kill = false): void => {
|
|
671
|
+
if (settled) return;
|
|
672
|
+
settled = true;
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
if (kill) {
|
|
675
|
+
try {
|
|
676
|
+
child.kill();
|
|
677
|
+
} catch {
|
|
678
|
+
/* already gone */
|
|
679
|
+
}
|
|
356
680
|
}
|
|
681
|
+
resolve(r);
|
|
357
682
|
};
|
|
358
|
-
let
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
683
|
+
let raw = "";
|
|
684
|
+
const forwarded = new Set<string>();
|
|
685
|
+
const acked = new Set<string>();
|
|
686
|
+
const saveToken = (token: string): void => {
|
|
687
|
+
upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
|
|
688
|
+
done({ ok: true, message: `${label} connected.` }, true);
|
|
689
|
+
};
|
|
690
|
+
const onChunk = (b: Buffer): void => {
|
|
691
|
+
raw += b.toString("utf8");
|
|
692
|
+
// A pending spinner redraws for minutes — keep only the tail (the token
|
|
693
|
+
// and final screens are at the end) so re-sanitizing stays cheap.
|
|
694
|
+
if (raw.length > 262_144) raw = raw.slice(-131_072);
|
|
695
|
+
const text = sanitizeTerminalOutput(raw);
|
|
696
|
+
// Act only on COMPLETE lines: a trailing partial may still be missing
|
|
697
|
+
// the rest of an escape sequence — or the rest of the TOKEN, and the
|
|
698
|
+
// extractor would happily return a truncated prefix.
|
|
699
|
+
const complete = text.slice(0, text.lastIndexOf("\n") + 1);
|
|
700
|
+
for (const line of significantLines(complete)) {
|
|
701
|
+
// Each distinct line once — Ink redraws the same screen constantly
|
|
702
|
+
// and would flood the UI log.
|
|
703
|
+
if (!forwarded.has(line)) {
|
|
704
|
+
forwarded.add(line);
|
|
705
|
+
onLog(line);
|
|
706
|
+
}
|
|
707
|
+
// Auto-acknowledge keypress gates (once per distinct screen text) —
|
|
708
|
+
// with a PTY the CLI accepts input, and nobody is at its keyboard.
|
|
709
|
+
if (/press enter/i.test(line) && !acked.has(line)) {
|
|
710
|
+
acked.add(line);
|
|
711
|
+
try {
|
|
712
|
+
child.stdin?.write("\r");
|
|
713
|
+
} catch {
|
|
714
|
+
/* best effort */
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// The whole point: capture the token the moment it renders. The CLI's
|
|
719
|
+
// final screen lingers for a keypress; we don't wait for exit.
|
|
720
|
+
const token = extractClaudeToken(complete);
|
|
721
|
+
if (token) saveToken(token);
|
|
722
|
+
};
|
|
723
|
+
child.stdout?.on("data", onChunk);
|
|
724
|
+
child.stderr?.on("data", onChunk);
|
|
372
725
|
child.on("error", (err: NodeJS.ErrnoException) =>
|
|
373
726
|
done({
|
|
374
727
|
ok: false,
|
|
@@ -379,27 +732,42 @@ function runTokenCommand(
|
|
|
379
732
|
}),
|
|
380
733
|
);
|
|
381
734
|
child.on("exit", () => {
|
|
382
|
-
const token = extractClaudeToken(
|
|
735
|
+
const token = extractClaudeToken(sanitizeTerminalOutput(raw));
|
|
383
736
|
if (!token) {
|
|
384
737
|
done({
|
|
385
738
|
ok: false,
|
|
386
|
-
message:
|
|
387
|
-
"Couldn't read a token from the CLI output. Try again, or paste the token manually.",
|
|
739
|
+
message: `The sign-in didn't finish. ${TOKEN_FLOW_FALLBACK}`,
|
|
388
740
|
});
|
|
389
741
|
return;
|
|
390
742
|
}
|
|
391
|
-
|
|
392
|
-
done({ ok: true, message: `${label} connected.` });
|
|
743
|
+
saveToken(token);
|
|
393
744
|
});
|
|
745
|
+
timer = setTimeout(
|
|
746
|
+
() =>
|
|
747
|
+
done(
|
|
748
|
+
{
|
|
749
|
+
ok: false,
|
|
750
|
+
message: `Timed out waiting for the browser authorization. ${TOKEN_FLOW_FALLBACK}`,
|
|
751
|
+
},
|
|
752
|
+
true,
|
|
753
|
+
),
|
|
754
|
+
TOKEN_FLOW_TIMEOUT_MS,
|
|
755
|
+
);
|
|
394
756
|
});
|
|
395
757
|
}
|
|
396
758
|
|
|
397
|
-
function runLoginCommand(
|
|
759
|
+
async function runLoginCommand(
|
|
398
760
|
kind: EngineKind,
|
|
399
761
|
label: string,
|
|
400
762
|
onLog: (line: string) => void,
|
|
401
763
|
s: EngineSeams,
|
|
402
764
|
): Promise<ConnectResult> {
|
|
765
|
+
let child: ChildProcess;
|
|
766
|
+
try {
|
|
767
|
+
child = await spawnEngineCli(kind, ["login"], s);
|
|
768
|
+
} catch (err) {
|
|
769
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
770
|
+
}
|
|
403
771
|
return new Promise((resolve) => {
|
|
404
772
|
let settled = false;
|
|
405
773
|
const done = (r: ConnectResult): void => {
|
|
@@ -408,13 +776,6 @@ function runLoginCommand(
|
|
|
408
776
|
resolve(r);
|
|
409
777
|
}
|
|
410
778
|
};
|
|
411
|
-
let child: ChildProcess;
|
|
412
|
-
try {
|
|
413
|
-
child = s.spawn(resolveBin(kind, s), ["login"]);
|
|
414
|
-
} catch (err) {
|
|
415
|
-
done({ ok: false, message: err instanceof Error ? err.message : String(err) });
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
779
|
child.stdout?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
|
|
419
780
|
child.stderr?.on("data", (b: Buffer) => relay(b.toString("utf8"), onLog));
|
|
420
781
|
child.on("error", (err: NodeJS.ErrnoException) =>
|
package/lib/standard-image.ts
CHANGED
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
|
-
import { existsSync } from "node:fs";
|
|
22
21
|
import { readdir, readFile } from "node:fs/promises";
|
|
23
|
-
import { homedir } from "node:os";
|
|
24
22
|
import { dirname, join, resolve } from "node:path";
|
|
25
23
|
import { fileURLToPath } from "node:url";
|
|
26
24
|
|
|
25
|
+
import { detectEngine } from "./engines";
|
|
26
|
+
|
|
27
27
|
/** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
|
|
28
28
|
export const STANDARD_IMAGE_TAG = "uai-standard:dev";
|
|
29
29
|
export const ASDF_DATA_VOLUME = "uai-asdf-data";
|
|
@@ -71,23 +71,21 @@ const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
|
|
|
71
71
|
*/
|
|
72
72
|
/**
|
|
73
73
|
* Which OPTIONAL agent CLIs the operator has actually configured — so the
|
|
74
|
-
* image installs only those, not every engine on every host.
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* image installs only those, not every engine on every host. Delegates to
|
|
75
|
+
* engines.ts detection so every connect flavor counts: kimi's copied cred
|
|
76
|
+
* file, grok's login file OR a pasted XAI_API_KEY, cursor's CURSOR_API_KEY
|
|
77
|
+
* (claude/codex are always baked). Folded into the build hash below, so
|
|
78
|
+
* connecting a new engine triggers a rebuild.
|
|
78
79
|
*/
|
|
79
80
|
export function configuredOptionalEngines(): {
|
|
80
81
|
kimi: boolean;
|
|
81
82
|
grok: boolean;
|
|
82
83
|
cursor: boolean;
|
|
83
84
|
} {
|
|
84
|
-
const home = process.env.UAI_OWNER_HOME?.trim() || homedir();
|
|
85
85
|
return {
|
|
86
|
-
kimi:
|
|
87
|
-
grok:
|
|
88
|
-
|
|
89
|
-
// config file — install it when the key is present.
|
|
90
|
-
cursor: Boolean(process.env.CURSOR_API_KEY),
|
|
86
|
+
kimi: detectEngine("kimi"),
|
|
87
|
+
grok: detectEngine("grok"),
|
|
88
|
+
cursor: detectEngine("cursor"),
|
|
91
89
|
};
|
|
92
90
|
}
|
|
93
91
|
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -365,6 +365,11 @@ fi
|
|
|
365
365
|
# claude and the code-server terminal's interactive claude authenticate.
|
|
366
366
|
printf ' %s: "${%s:-}"\n' \
|
|
367
367
|
"CLAUDE_CODE_OAUTH_TOKEN" "CLAUDE_CODE_OAUTH_TOKEN"
|
|
368
|
+
# Pasted engine API keys (ADR-070) ride the same at-up interpolation, so the
|
|
369
|
+
# terminal CLIs authenticate like the adapters' exec-time env forwarding.
|
|
370
|
+
printf ' %s: "${%s:-}"\n' "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEY"
|
|
371
|
+
printf ' %s: "${%s:-}"\n' "ANTHROPIC_AUTH_TOKEN" "ANTHROPIC_AUTH_TOKEN"
|
|
372
|
+
printf ' %s: "${%s:-}"\n' "XAI_API_KEY" "XAI_API_KEY"
|
|
368
373
|
# ADR-053: point Playwright at the shared browser cache volume.
|
|
369
374
|
printf ' PLAYWRIGHT_BROWSERS_PATH: "/opt/pw-browsers"\n'
|
|
370
375
|
# No GH_TOKEN/GITHUB_TOKEN in the container env (ADR-027). `gh` prefers such
|
package/src/ui/types.ts
CHANGED
|
@@ -81,6 +81,9 @@ export const EngineCatalogEntry = z.object({
|
|
|
81
81
|
authMode: z.enum(["token-command", "login-command", "api-key"]),
|
|
82
82
|
notes: z.string().nullable(),
|
|
83
83
|
getKeyUrl: z.string().nullable(),
|
|
84
|
+
// Pasted-API-key alternative to the login/token flow (null = not offered).
|
|
85
|
+
apiKeyHint: z.string().nullable(),
|
|
86
|
+
apiKeyUrl: z.string().nullable(),
|
|
84
87
|
});
|
|
85
88
|
export type EngineCatalogEntry = z.infer<typeof EngineCatalogEntry>;
|
|
86
89
|
|
package/ui/app.js
CHANGED
|
@@ -516,19 +516,25 @@ function commandForm(e) {
|
|
|
516
516
|
actions.append(connect);
|
|
517
517
|
form.append(actions, status, log);
|
|
518
518
|
|
|
519
|
-
//
|
|
520
|
-
|
|
519
|
+
// Manual paste fallback for when the browser flow isn't possible: Claude
|
|
520
|
+
// takes a token or API key (one box, classified server-side); login-command
|
|
521
|
+
// engines with an apiKeyHint (Codex, Grok) take an API key.
|
|
522
|
+
const isToken = e.authMode === "token-command";
|
|
523
|
+
if (isToken || e.apiKeyHint) {
|
|
524
|
+
const saveLabel = isToken ? "Save token" : "Save key";
|
|
521
525
|
const pasteToggle = document.createElement("button");
|
|
522
526
|
pasteToggle.className = "link-btn";
|
|
523
527
|
pasteToggle.type = "button";
|
|
524
|
-
pasteToggle.textContent =
|
|
528
|
+
pasteToggle.textContent = isToken
|
|
529
|
+
? "Paste a token or API key instead"
|
|
530
|
+
: "Use an API key instead";
|
|
525
531
|
const pasteWrap = document.createElement("div");
|
|
526
532
|
pasteWrap.className = "engine-setup";
|
|
527
533
|
pasteWrap.hidden = true;
|
|
528
534
|
const pInput = document.createElement("input");
|
|
529
535
|
pInput.type = "password";
|
|
530
536
|
pInput.className = "text-input";
|
|
531
|
-
pInput.placeholder = "sk-ant
|
|
537
|
+
pInput.placeholder = e.apiKeyHint || "sk-ant-…";
|
|
532
538
|
pInput.autocomplete = "off";
|
|
533
539
|
pInput.spellcheck = false;
|
|
534
540
|
const pField = document.createElement("div");
|
|
@@ -539,30 +545,42 @@ function commandForm(e) {
|
|
|
539
545
|
const pSave = document.createElement("button");
|
|
540
546
|
pSave.className = "btn";
|
|
541
547
|
pSave.type = "button";
|
|
542
|
-
pSave.textContent =
|
|
548
|
+
pSave.textContent = saveLabel;
|
|
543
549
|
pSave.addEventListener("click", async () => {
|
|
544
|
-
const
|
|
545
|
-
if (!
|
|
550
|
+
const value = pInput.value.trim();
|
|
551
|
+
if (!value) {
|
|
546
552
|
pStatus.className = "setup-status err";
|
|
547
|
-
pStatus.textContent = "Paste a token first.";
|
|
553
|
+
pStatus.textContent = isToken ? "Paste a token first." : "Paste a key first.";
|
|
548
554
|
return;
|
|
549
555
|
}
|
|
550
556
|
pSave.disabled = true;
|
|
551
557
|
pSave.textContent = "Saving…";
|
|
552
|
-
const
|
|
558
|
+
const body = isToken
|
|
559
|
+
? { kind: e.kind, pastedToken: value }
|
|
560
|
+
: { kind: e.kind, apiKey: value };
|
|
561
|
+
const result = await runConnect(body);
|
|
553
562
|
if (result.ok) {
|
|
554
563
|
await poll();
|
|
555
564
|
closeModal();
|
|
556
565
|
} else {
|
|
557
566
|
pSave.disabled = false;
|
|
558
|
-
pSave.textContent =
|
|
567
|
+
pSave.textContent = saveLabel;
|
|
559
568
|
pStatus.className = "setup-status err";
|
|
560
|
-
pStatus.textContent = result.message || "Couldn't save
|
|
569
|
+
pStatus.textContent = result.message || "Couldn't save it.";
|
|
561
570
|
}
|
|
562
571
|
});
|
|
563
572
|
const pActions = document.createElement("div");
|
|
564
573
|
pActions.className = "setup-actions";
|
|
565
574
|
pActions.append(pSave);
|
|
575
|
+
if (e.apiKeyUrl) {
|
|
576
|
+
const link = document.createElement("a");
|
|
577
|
+
link.className = "link-btn";
|
|
578
|
+
link.href = e.apiKeyUrl;
|
|
579
|
+
link.target = "_blank";
|
|
580
|
+
link.rel = "noreferrer";
|
|
581
|
+
link.textContent = "Get a key";
|
|
582
|
+
pActions.append(link);
|
|
583
|
+
}
|
|
566
584
|
pasteWrap.append(pField, pActions, pStatus);
|
|
567
585
|
pasteToggle.addEventListener("click", () => {
|
|
568
586
|
pasteWrap.hidden = !pasteWrap.hidden;
|