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