@hue-run/sdk 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,712 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import { access, chmod, lstat, open, readFile, rename, stat, unlink } from "node:fs/promises";
5
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { createInterface } from "node:readline";
7
+ import { Writable } from "node:stream";
8
+ import { parseArgs } from "node:util";
9
+ import { isLoopbackHost } from "../config.js";
10
+ const DEFAULT_ORIGIN = "https://app.hue.run";
11
+ const DEFAULT_ENV_FILE = ".env.hue";
12
+ /** Settings section that lists and creates project service keys. */
13
+ const KEY_SETTINGS_PATH = "/settings/integrations";
14
+ const REQUEST_TIMEOUT_MILLIS = 10_000;
15
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
16
+ const MAX_ENV_FILE_BYTES = 1024 * 1024;
17
+ const MAX_KEY_LENGTH = 4096;
18
+ const KEY_KINDS = {
19
+ evaluations: {
20
+ variable: "HUE_API_KEY",
21
+ urlVariable: "HUE_BASE_URL",
22
+ preset: "Tracing and evaluations",
23
+ },
24
+ "coding-agent": {
25
+ variable: "HUE_MCP_KEY",
26
+ urlVariable: "HUE_MCP_URL",
27
+ preset: "Coding agent (read + evaluations)",
28
+ },
29
+ };
30
+ export const LOGIN_USAGE = `Usage: hue login [--origin URL] [--env-file PATH] [--keys evaluations|coding-agent|both]
31
+ [--no-browser] [--force] [--gitignore]
32
+
33
+ Store keys you created in Hue in a private env file. Each key is validated against Hue before it
34
+ is stored; key values are never printed.
35
+
36
+ Options:
37
+ --origin URL Hue origin (default ${DEFAULT_ORIGIN})
38
+ --env-file PATH Env file to write (default ${DEFAULT_ENV_FILE} in the current directory)
39
+ --keys KIND evaluations (HUE_API_KEY), coding-agent (HUE_MCP_KEY) or both (default both)
40
+ --no-browser Do not open the key settings page in a browser
41
+ --force Replace an existing different value in the env file
42
+ --gitignore Add the env file to .gitignore when a git repository does not ignore it
43
+ -h, --help Show this help`;
44
+ /** Hue MCP endpoint that pairs with an application origin. */
45
+ export function mcpUrlForOrigin(origin) {
46
+ if (origin === "https://app.hue.run")
47
+ return "https://mcp.hue.run/mcp";
48
+ if (origin === "https://staging.hue.run")
49
+ return "https://mcp.staging.hue.run/mcp";
50
+ return `${origin}/api/mcp`;
51
+ }
52
+ /** Normalizes a Hue origin: HTTPS, or HTTP for loopback only; no credentials, path, query or hash. */
53
+ export function parseHueOrigin(value) {
54
+ let url;
55
+ try {
56
+ url = new URL(value);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname)))
62
+ return null;
63
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash)
64
+ return null;
65
+ return url.origin;
66
+ }
67
+ /** Explains why a pasted value cannot be a Hue key, or returns null when it is acceptable. */
68
+ export function invalidKeyReason(value) {
69
+ if (!value)
70
+ return "No key was entered.";
71
+ if (/\s/u.test(value))
72
+ return "A key cannot contain whitespace.";
73
+ if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(value))
74
+ return "That looks like a URL, not a key.";
75
+ if (value.length > MAX_KEY_LENGTH || value.includes("\u0000"))
76
+ return "That does not look like a Hue key.";
77
+ return null;
78
+ }
79
+ class PromptInterrupted extends Error {
80
+ constructor() {
81
+ super("Interrupted");
82
+ this.name = "PromptInterrupted";
83
+ }
84
+ }
85
+ function isTTY(stream) {
86
+ return stream.isTTY === true;
87
+ }
88
+ /** Interactive terminal input: readline handles editing, and its echo is muted while typing. */
89
+ function createTerminalPrompter(stdin, stdout) {
90
+ let muted = false;
91
+ const output = new Writable({
92
+ write(chunk, _encoding, callback) {
93
+ if (!muted)
94
+ stdout.write(chunk);
95
+ callback();
96
+ },
97
+ });
98
+ const rl = createInterface({ input: stdin, output, terminal: true, historySize: 0 });
99
+ let interrupted = false;
100
+ rl.on("SIGINT", () => {
101
+ interrupted = true;
102
+ rl.close();
103
+ });
104
+ return {
105
+ ask(prompt) {
106
+ return new Promise((resolvePrompt, rejectPrompt) => {
107
+ if (interrupted) {
108
+ rejectPrompt(new PromptInterrupted());
109
+ return;
110
+ }
111
+ stdout.write(prompt);
112
+ muted = true;
113
+ let settled = false;
114
+ const finish = (answer) => {
115
+ if (settled)
116
+ return;
117
+ settled = true;
118
+ muted = false;
119
+ rl.removeListener("close", onClose);
120
+ stdout.write("\n");
121
+ if (interrupted)
122
+ rejectPrompt(new PromptInterrupted());
123
+ else
124
+ resolvePrompt(answer);
125
+ };
126
+ const onClose = () => finish(null);
127
+ rl.once("close", onClose);
128
+ rl.question("", (answer) => finish(answer));
129
+ });
130
+ },
131
+ close() {
132
+ rl.close();
133
+ },
134
+ };
135
+ }
136
+ /** Piped input: one line per key, without echo, so scripts and tests can supply keys. */
137
+ function createLinePrompter(stdin, stdout) {
138
+ const rl = createInterface({ input: stdin, terminal: false, crlfDelay: Infinity });
139
+ const lines = [];
140
+ const waiting = [];
141
+ let closed = false;
142
+ rl.on("line", (line) => {
143
+ const next = waiting.shift();
144
+ if (next)
145
+ next(line);
146
+ else
147
+ lines.push(line);
148
+ });
149
+ rl.on("close", () => {
150
+ closed = true;
151
+ for (const next of waiting.splice(0))
152
+ next(null);
153
+ });
154
+ return {
155
+ ask(prompt) {
156
+ stdout.write(prompt);
157
+ const queued = lines.shift();
158
+ if (queued !== undefined || closed) {
159
+ stdout.write("\n");
160
+ return Promise.resolve(queued ?? null);
161
+ }
162
+ return new Promise((resolveLine) => {
163
+ waiting.push((line) => {
164
+ stdout.write("\n");
165
+ resolveLine(line);
166
+ });
167
+ });
168
+ },
169
+ close() {
170
+ rl.close();
171
+ },
172
+ };
173
+ }
174
+ function defaultOpenBrowser(url) {
175
+ const command = process.platform === "darwin"
176
+ ? { executable: "open", args: [url] }
177
+ : process.platform === "win32"
178
+ ? { executable: "cmd", args: ["/c", "start", "", url] }
179
+ : { executable: "xdg-open", args: [url] };
180
+ return new Promise((resolveOpen) => {
181
+ try {
182
+ const child = spawn(command.executable, command.args, { stdio: "ignore", detached: true });
183
+ child.once("error", () => resolveOpen(false));
184
+ child.once("spawn", () => {
185
+ child.unref();
186
+ resolveOpen(true);
187
+ });
188
+ }
189
+ catch {
190
+ resolveOpen(false);
191
+ }
192
+ });
193
+ }
194
+ async function readBoundedText(response) {
195
+ const reader = response.body?.getReader();
196
+ if (!reader)
197
+ return "";
198
+ const chunks = [];
199
+ let size = 0;
200
+ try {
201
+ for (;;) {
202
+ const { done, value } = await reader.read();
203
+ if (done)
204
+ break;
205
+ size += value.byteLength;
206
+ if (size > MAX_RESPONSE_BYTES)
207
+ throw new Error("Oversized response");
208
+ chunks.push(value);
209
+ }
210
+ }
211
+ finally {
212
+ await reader.cancel().catch(() => undefined);
213
+ }
214
+ return Buffer.concat(chunks).toString("utf8");
215
+ }
216
+ function isRecord(value) {
217
+ return typeof value === "object" && value !== null && !Array.isArray(value);
218
+ }
219
+ /** Mirrors `checkConnection()`: `GET /api/v1/projects/current` with the key, no redirects. */
220
+ async function checkEvaluationsKey(fetchImpl, origin, apiKey) {
221
+ let response;
222
+ try {
223
+ response = await fetchImpl(`${origin}/api/v1/projects/current`, {
224
+ method: "GET",
225
+ headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" },
226
+ redirect: "error",
227
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MILLIS),
228
+ });
229
+ }
230
+ catch {
231
+ return { ok: false, rejected: false, detail: `Could not reach ${origin}.` };
232
+ }
233
+ if (response.status === 401 || response.status === 403) {
234
+ await response.body?.cancel();
235
+ return {
236
+ ok: false,
237
+ rejected: true,
238
+ detail: `Hue rejected the evaluations key (HTTP ${response.status}).`,
239
+ };
240
+ }
241
+ if (!response.ok) {
242
+ await response.body?.cancel();
243
+ return {
244
+ ok: false,
245
+ rejected: false,
246
+ detail: `Hue answered HTTP ${response.status} while checking the evaluations key.`,
247
+ };
248
+ }
249
+ try {
250
+ const project = JSON.parse(await readBoundedText(response));
251
+ if (!isRecord(project) || typeof project.name !== "string")
252
+ throw new Error("Invalid project");
253
+ return { ok: true, detail: project.name };
254
+ }
255
+ catch {
256
+ return { ok: false, rejected: false, detail: "Hue returned an unexpected project response." };
257
+ }
258
+ }
259
+ /** Reads JSON-RPC messages from a JSON body or a `text/event-stream` body. */
260
+ function parseJsonRpcMessages(text, contentType) {
261
+ const essence = contentType?.split(";", 1)[0]?.trim().toLowerCase();
262
+ if (essence !== "text/event-stream")
263
+ return [JSON.parse(text)];
264
+ const messages = [];
265
+ for (const event of text.split(/\r?\n\r?\n/u)) {
266
+ const data = event
267
+ .split(/\r?\n/u)
268
+ .filter((line) => line.startsWith("data:"))
269
+ .map((line) => line.slice(5).trim())
270
+ .join("");
271
+ if (data)
272
+ messages.push(JSON.parse(data));
273
+ }
274
+ return messages;
275
+ }
276
+ /** Lists MCP tools with the key: a `tools/list` result proves the key reaches the MCP server. */
277
+ async function checkCodingAgentKey(fetchImpl, mcpUrl, apiKey) {
278
+ let response;
279
+ try {
280
+ response = await fetchImpl(mcpUrl, {
281
+ method: "POST",
282
+ headers: {
283
+ authorization: `Bearer ${apiKey}`,
284
+ "content-type": "application/json",
285
+ accept: "application/json, text/event-stream",
286
+ },
287
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
288
+ redirect: "error",
289
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MILLIS),
290
+ });
291
+ }
292
+ catch {
293
+ return { ok: false, rejected: false, detail: `Could not reach ${mcpUrl}.` };
294
+ }
295
+ if (response.status === 401 || response.status === 403) {
296
+ await response.body?.cancel();
297
+ return {
298
+ ok: false,
299
+ rejected: true,
300
+ detail: `The Hue MCP server rejected the coding-agent key (HTTP ${response.status}).`,
301
+ };
302
+ }
303
+ if (response.status !== 200) {
304
+ await response.body?.cancel();
305
+ return {
306
+ ok: false,
307
+ rejected: false,
308
+ detail: `The Hue MCP server answered HTTP ${response.status} while listing tools.`,
309
+ };
310
+ }
311
+ try {
312
+ const messages = parseJsonRpcMessages(await readBoundedText(response), response.headers.get("content-type"));
313
+ for (const message of messages) {
314
+ if (!isRecord(message) || message.id !== 1)
315
+ continue;
316
+ if (isRecord(message.error))
317
+ return {
318
+ ok: false,
319
+ rejected: false,
320
+ detail: `The Hue MCP server returned a JSON-RPC error (code ${String(message.error.code)}).`,
321
+ };
322
+ if (isRecord(message.result) && Array.isArray(message.result.tools))
323
+ return { ok: true, detail: String(message.result.tools.length) };
324
+ }
325
+ throw new Error("No tools/list result");
326
+ }
327
+ catch {
328
+ return {
329
+ ok: false,
330
+ rejected: false,
331
+ detail: "The Hue MCP server returned an unexpected tools/list response.",
332
+ };
333
+ }
334
+ }
335
+ class EnvFileError extends Error {
336
+ constructor(message) {
337
+ super(message);
338
+ this.name = "EnvFileError";
339
+ }
340
+ }
341
+ async function rejectSymlink(path) {
342
+ try {
343
+ if ((await lstat(path)).isSymbolicLink())
344
+ throw new EnvFileError(`Refusing to write ${path}: it is a symbolic link.`);
345
+ }
346
+ catch (error) {
347
+ if (error.code === "ENOENT")
348
+ return;
349
+ throw error;
350
+ }
351
+ }
352
+ async function readEnvFile(path) {
353
+ let info;
354
+ try {
355
+ info = await lstat(path);
356
+ }
357
+ catch (error) {
358
+ if (error.code === "ENOENT")
359
+ return { text: "", exists: false };
360
+ throw new EnvFileError(`Cannot read ${path}: ${error.message}`);
361
+ }
362
+ if (info.isSymbolicLink())
363
+ throw new EnvFileError(`Refusing to use ${path}: it is a symbolic link.`);
364
+ if (!info.isFile())
365
+ throw new EnvFileError(`Refusing to use ${path}: it is not a regular file.`);
366
+ if (info.size > MAX_ENV_FILE_BYTES)
367
+ throw new EnvFileError(`Refusing to use ${path}: it is larger than 1 MiB.`);
368
+ return { text: await readFile(path, "utf8"), exists: true };
369
+ }
370
+ const ASSIGNMENT = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/u;
371
+ function unquote(raw) {
372
+ const value = raw.trim();
373
+ const quoted = /^(["'`])(.*?)\1(?:\s*(?:#.*)?)$/su.exec(value);
374
+ if (quoted)
375
+ return quoted[2] ?? "";
376
+ const comment = value.indexOf(" #");
377
+ return (comment >= 0 ? value.slice(0, comment) : value).trim();
378
+ }
379
+ /** Reads the effective value of a variable from env-file text; the last assignment wins. */
380
+ export function readEnvValue(text, variable) {
381
+ let found;
382
+ for (const line of text.split("\n")) {
383
+ const match = ASSIGNMENT.exec(line);
384
+ if (match && match[2] === variable)
385
+ found = unquote(match[3] ?? "");
386
+ }
387
+ return found;
388
+ }
389
+ /** Replaces or appends the given assignments while preserving every other line. */
390
+ export function mergeEnvText(text, entries) {
391
+ const pending = new Map(Object.entries(entries));
392
+ const lines = text === "" ? [] : text.split("\n");
393
+ if (text.endsWith("\n"))
394
+ lines.pop();
395
+ const output = [];
396
+ for (const line of lines) {
397
+ const match = ASSIGNMENT.exec(line);
398
+ const variable = match?.[2];
399
+ if (variable !== undefined && Object.hasOwn(entries, variable)) {
400
+ // The first assignment is rewritten in place; later duplicates of a managed variable are dropped.
401
+ if (pending.has(variable)) {
402
+ output.push(`${match?.[1] ?? ""}${variable}=${entries[variable]}`);
403
+ pending.delete(variable);
404
+ }
405
+ continue;
406
+ }
407
+ output.push(line);
408
+ }
409
+ for (const [variable, value] of pending)
410
+ output.push(`${variable}=${value}`);
411
+ return output.length ? `${output.join("\n")}\n` : "";
412
+ }
413
+ /** Writes owner-only content through a private temporary file and an atomic rename. */
414
+ async function writePrivateFile(path, text, mode) {
415
+ await rejectSymlink(path);
416
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
417
+ try {
418
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);
419
+ try {
420
+ await handle.writeFile(text, "utf8");
421
+ await handle.sync();
422
+ }
423
+ finally {
424
+ await handle.close();
425
+ }
426
+ await chmod(temporary, mode);
427
+ await rejectSymlink(path);
428
+ await rename(temporary, path);
429
+ }
430
+ catch (error) {
431
+ await unlink(temporary).catch(() => undefined);
432
+ throw error;
433
+ }
434
+ }
435
+ async function findExecutable(name, env) {
436
+ const searchPath = env.PATH ?? env.Path ?? "";
437
+ const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
438
+ for (const directory of searchPath.split(delimiter)) {
439
+ if (!directory)
440
+ continue;
441
+ for (const extension of extensions) {
442
+ const candidate = join(directory, `${name}${extension.toLowerCase()}`);
443
+ try {
444
+ if (!(await stat(candidate)).isFile())
445
+ continue;
446
+ if (process.platform !== "win32")
447
+ await access(candidate, constants.X_OK);
448
+ return candidate;
449
+ }
450
+ catch {
451
+ continue;
452
+ }
453
+ }
454
+ }
455
+ return null;
456
+ }
457
+ function runQuiet(executable, args, cwd, env) {
458
+ return new Promise((resolveRun) => {
459
+ try {
460
+ const child = spawn(executable, args, { cwd, env, stdio: "ignore" });
461
+ child.once("error", () => resolveRun(null));
462
+ child.once("close", (code) => resolveRun(code));
463
+ }
464
+ catch {
465
+ resolveRun(null);
466
+ }
467
+ });
468
+ }
469
+ async function insideGitRepository(directory) {
470
+ let current = directory;
471
+ for (;;) {
472
+ try {
473
+ await lstat(join(current, ".git"));
474
+ return true;
475
+ }
476
+ catch {
477
+ // Not at the repository root yet.
478
+ }
479
+ const parent = dirname(current);
480
+ if (parent === current)
481
+ return false;
482
+ current = parent;
483
+ }
484
+ }
485
+ async function gitIgnoreStatus(envPath, env) {
486
+ const directory = dirname(envPath);
487
+ if (!(await insideGitRepository(directory)))
488
+ return "outside-repository";
489
+ const git = await findExecutable("git", env);
490
+ if (!git)
491
+ return "unknown";
492
+ const code = await runQuiet(git, ["check-ignore", "-q", "--", basename(envPath)], directory, env);
493
+ if (code === 0)
494
+ return "ignored";
495
+ if (code === 1)
496
+ return "not-ignored";
497
+ return "unknown";
498
+ }
499
+ async function appendIgnoreRule(ignorePath, rule) {
500
+ let text = "";
501
+ let mode = 0o644;
502
+ try {
503
+ const info = await lstat(ignorePath);
504
+ if (info.isSymbolicLink() || !info.isFile())
505
+ throw new EnvFileError(`Refusing to edit ${ignorePath}: it is not a regular file.`);
506
+ if (info.size > MAX_ENV_FILE_BYTES)
507
+ throw new EnvFileError(`Refusing to edit ${ignorePath}: it is larger than 1 MiB.`);
508
+ mode = info.mode & 0o777;
509
+ text = await readFile(ignorePath, "utf8");
510
+ }
511
+ catch (error) {
512
+ if (error.code !== "ENOENT")
513
+ throw error;
514
+ }
515
+ if (text.split(/\r?\n/u).includes(rule))
516
+ return;
517
+ const separator = text.length === 0 || text.endsWith("\n") ? "" : "\n";
518
+ await writePrivateFile(ignorePath, `${text}${separator}${rule}\n`, mode);
519
+ }
520
+ function parseLoginArguments(argv) {
521
+ return parseArgs({
522
+ args: argv,
523
+ allowPositionals: true,
524
+ strict: true,
525
+ options: {
526
+ origin: { type: "string" },
527
+ "env-file": { type: "string" },
528
+ keys: { type: "string" },
529
+ "no-browser": { type: "boolean", default: false },
530
+ force: { type: "boolean", default: false },
531
+ gitignore: { type: "boolean", default: false },
532
+ help: { type: "boolean", short: "h", default: false },
533
+ },
534
+ });
535
+ }
536
+ function displayPath(cwd, path) {
537
+ const shown = relative(cwd, path);
538
+ return shown && !shown.startsWith("..") && !isAbsolute(shown) ? shown : path;
539
+ }
540
+ /**
541
+ * Runs `hue login` and returns the process exit code: 0 stored, 1 failed, 2 usage error, 130
542
+ * interrupted. `argv` may start with the `login` command word.
543
+ */
544
+ export async function runLoginCommand(argv, io = {}) {
545
+ const stdin = io.stdin ?? process.stdin;
546
+ const stdout = io.stdout ?? process.stdout;
547
+ const stderr = io.stderr ?? process.stderr;
548
+ const env = io.env ?? process.env;
549
+ const cwd = io.cwd ?? process.cwd();
550
+ const fetchImpl = io.fetch ?? globalThis.fetch;
551
+ const openBrowser = io.openBrowser ?? defaultOpenBrowser;
552
+ const out = (line) => {
553
+ stdout.write(`${line}\n`);
554
+ };
555
+ const fail = (message, code = 1) => {
556
+ stderr.write(`${message}\n`);
557
+ return code;
558
+ };
559
+ let parsed;
560
+ try {
561
+ parsed = parseLoginArguments(argv);
562
+ }
563
+ catch (error) {
564
+ return fail(`${error.message}\n\n${LOGIN_USAGE}`, 2);
565
+ }
566
+ if (parsed.values.help) {
567
+ out(LOGIN_USAGE);
568
+ return 0;
569
+ }
570
+ const positionals = parsed.positionals[0] === "login" ? parsed.positionals.slice(1) : parsed.positionals;
571
+ if (positionals.length > 0)
572
+ return fail(`Unexpected argument: ${positionals[0]}\n\n${LOGIN_USAGE}`, 2);
573
+ const keysOption = parsed.values.keys ?? "both";
574
+ if (keysOption !== "evaluations" && keysOption !== "coding-agent" && keysOption !== "both")
575
+ return fail(`--keys must be evaluations, coding-agent or both.\n\n${LOGIN_USAGE}`, 2);
576
+ const kinds = keysOption === "both" ? ["evaluations", "coding-agent"] : [keysOption];
577
+ const origin = parseHueOrigin(parsed.values.origin ?? DEFAULT_ORIGIN);
578
+ if (!origin)
579
+ return fail("--origin must be an HTTPS origin such as https://app.hue.run (plain HTTP is accepted for loopback test servers only).", 2);
580
+ const mcpUrl = mcpUrlForOrigin(origin);
581
+ const envPath = resolve(cwd, parsed.values["env-file"] ?? DEFAULT_ENV_FILE);
582
+ const envDisplay = displayPath(cwd, envPath);
583
+ let envFile;
584
+ try {
585
+ envFile = await readEnvFile(envPath);
586
+ }
587
+ catch (error) {
588
+ return fail(error.message);
589
+ }
590
+ const settingsUrl = `${origin}${KEY_SETTINGS_PATH}`;
591
+ out("Hue keys are created in the app; this command validates and stores them locally.");
592
+ out(`Create keys at: ${settingsUrl}`);
593
+ for (const kind of kinds)
594
+ out(` ${KEY_KINDS[kind].variable}: a "${KEY_KINDS[kind].preset}" key`);
595
+ if (!parsed.values["no-browser"] && isTTY(stdout)) {
596
+ const opened = await openBrowser(settingsUrl).catch(() => false);
597
+ if (opened)
598
+ out("Opened the key settings page in your browser.");
599
+ }
600
+ for (const kind of kinds) {
601
+ const { variable } = KEY_KINDS[kind];
602
+ if (readEnvValue(envFile.text, variable) !== undefined)
603
+ out(`${variable} is already stored in ${envDisplay}; a different value requires --force.`);
604
+ }
605
+ const stored = [];
606
+ // A key written before a later prompt fails still has to reach the ignore protection below, so
607
+ // the loop records why it stopped instead of returning past it.
608
+ let failure;
609
+ const prompter = isTTY(stdin)
610
+ ? createTerminalPrompter(stdin, stdout)
611
+ : createLinePrompter(stdin, stdout);
612
+ const nothingElse = () => (stored.length ? "No further key was stored." : "Nothing was stored.");
613
+ try {
614
+ for (const kind of kinds) {
615
+ const { variable, urlVariable, preset } = KEY_KINDS[kind];
616
+ const urlValue = kind === "evaluations" ? origin : mcpUrl;
617
+ const answer = await prompter.ask(`Paste the "${preset}" key (${variable}): `);
618
+ if (answer === null) {
619
+ failure = { message: `No ${variable} was entered; input ended.` };
620
+ break;
621
+ }
622
+ const value = answer.trim();
623
+ const reason = invalidKeyReason(value);
624
+ if (reason) {
625
+ failure = {
626
+ message: `${reason} Create a "${preset}" key at ${settingsUrl} and paste it.`,
627
+ };
628
+ break;
629
+ }
630
+ if (!parsed.values.force) {
631
+ for (const [name, next] of [
632
+ [variable, value],
633
+ [urlVariable, urlValue],
634
+ ]) {
635
+ const current = readEnvValue(envFile.text, name);
636
+ if (current !== undefined && current !== next)
637
+ failure ??= {
638
+ message: `${name} in ${envDisplay} already has a different value; rerun with --force to replace it.`,
639
+ };
640
+ }
641
+ if (failure)
642
+ break;
643
+ }
644
+ const check = kind === "evaluations"
645
+ ? await checkEvaluationsKey(fetchImpl, origin, value)
646
+ : await checkCodingAgentKey(fetchImpl, mcpUrl, value);
647
+ if (!check.ok) {
648
+ failure = {
649
+ message: check.rejected
650
+ ? `${check.detail} Create a "${preset}" key at ${settingsUrl} and try again.`
651
+ : `${check.detail} ${nothingElse()}`,
652
+ };
653
+ break;
654
+ }
655
+ out(kind === "evaluations"
656
+ ? `Evaluations key accepted for project "${check.detail}".`
657
+ : `Coding-agent key accepted; the Hue MCP server lists ${check.detail} tools.`);
658
+ const text = mergeEnvText(envFile.text, { [variable]: value, [urlVariable]: urlValue });
659
+ try {
660
+ await writePrivateFile(envPath, text, 0o600);
661
+ }
662
+ catch (error) {
663
+ failure = { message: `Could not write ${envDisplay}: ${error.message}` };
664
+ break;
665
+ }
666
+ envFile = { text, exists: true };
667
+ stored.push(kind);
668
+ out(`Stored ${variable} (${value.length} chars) and ${urlVariable} in ${envDisplay}.`);
669
+ }
670
+ }
671
+ catch (error) {
672
+ failure =
673
+ error instanceof PromptInterrupted
674
+ ? { message: `Interrupted; ${nothingElse().toLowerCase()}`, code: 130 }
675
+ : { message: `hue login failed: ${error.message}` };
676
+ }
677
+ finally {
678
+ prompter.close();
679
+ }
680
+ // Only reached when a key actually landed in the file: a run that stored nothing has no new
681
+ // secret to protect and should not warn about a file it did not write.
682
+ if (stored.length) {
683
+ const ignorePath = join(dirname(envPath), ".gitignore");
684
+ const status = await gitIgnoreStatus(envPath, env);
685
+ if (status === "not-ignored") {
686
+ if (parsed.values.gitignore) {
687
+ try {
688
+ await appendIgnoreRule(ignorePath, basename(envPath));
689
+ out(`Added ${basename(envPath)} to ${displayPath(cwd, ignorePath)}.`);
690
+ }
691
+ catch (error) {
692
+ stderr.write(`Warning: could not update ${displayPath(cwd, ignorePath)}: ${error.message}\n`);
693
+ }
694
+ }
695
+ else {
696
+ stderr.write(`Warning: ${envDisplay} is not ignored by git. Rerun with --gitignore or add it to .gitignore before committing.\n`);
697
+ }
698
+ }
699
+ else if (status === "unknown") {
700
+ stderr.write(`Warning: could not confirm that git ignores ${envDisplay}; make sure it is never committed.\n`);
701
+ }
702
+ }
703
+ if (failure)
704
+ return fail(failure.message, failure.code);
705
+ out("Next steps:");
706
+ if (stored.includes("coding-agent"))
707
+ // `hue mcp install` defaults to production; a non-default origin needs its own endpoint.
708
+ out(` hue mcp install --client claude-code${mcpUrl === mcpUrlForOrigin(DEFAULT_ORIGIN) ? "" : ` --url ${mcpUrl}`}`);
709
+ if (stored.includes("evaluations"))
710
+ out(` hue eval --scenario "<name>" ./hue-agent.ts --env-file ${envDisplay}`);
711
+ return 0;
712
+ }