agent-runway 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.mjs ADDED
@@ -0,0 +1,412 @@
1
+ // Guided, cross-platform setup: obtain a token, validate it, persist it.
2
+ //
3
+ // The token is written to ~/.claude/usage-token (0600) rather than an
4
+ // environment variable by default. An env var on macOS/Linux means writing the
5
+ // secret into a shell rc file, which is frequently mode 644 and sometimes
6
+ // committed to a dotfiles repository; a single 0600 file is safer, identical on
7
+ // all three platforms, read automatically on every invocation, and revoked by
8
+ // deleting it. `--env` remains available, but on POSIX it exports an
9
+ // indirection to that file rather than a second copy of the secret.
10
+
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { spawn } from "node:child_process";
15
+ import { createInterface } from "node:readline";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ import { fetchUsage, UsageError, VERSION } from "./core.mjs";
19
+ import { renderTable } from "./render.mjs";
20
+
21
+ const IS_WINDOWS = process.platform === "win32";
22
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
+
24
+ const tokenPath = (home = os.homedir()) => path.join(home, ".claude", "usage-token");
25
+ const out = (line = "") => process.stdout.write(line + "\n");
26
+
27
+ // Key handling compares byte values rather than character literals: control
28
+ // characters in source are silently mangled by copy/paste, diff tools and
29
+ // editors, and a broken Ctrl-C handler would not be obvious.
30
+ const CTRL_C = 3;
31
+ const CTRL_D = 4;
32
+ const BACKSPACE = 8;
33
+ const LINE_FEED = 10;
34
+ const CARRIAGE_RETURN = 13;
35
+ const ESCAPE = 27;
36
+ const DELETE = 127;
37
+
38
+ const CSI = String.fromCharCode(27) + "[";
39
+ const BRACKETED_PASTE_OFF = CSI + "?2004l";
40
+ const BRACKETED_PASTE_ON = CSI + "?2004h";
41
+
42
+ // ---------------------------------------------------------------- pure helpers
43
+
44
+ /**
45
+ * Catches an obviously mangled paste, nothing more. Deliberately permissive
46
+ * about the character set: the token format is not published, and the
47
+ * authoritative check is the API call that follows, so a strict charset here
48
+ * could only ever reject a legitimate token.
49
+ */
50
+ export function tokenLooksValid(token) {
51
+ return typeof token === "string" && /^sk-ant-\S{16,}$/.test(token.trim());
52
+ }
53
+
54
+ /**
55
+ * Which shell rc file to append to, given the user's shell.
56
+ * Returns null when the shell is unknown rather than guessing wrong.
57
+ */
58
+ export function shellProfilePath(env = process.env, home = os.homedir(), platform = process.platform) {
59
+ if (platform === "win32") return null;
60
+ const shell = path.basename(env.SHELL ?? "");
61
+ if (shell === "zsh") return path.join(home, ".zshrc");
62
+ if (shell === "bash") {
63
+ // macOS bash reads .bash_profile for login shells, Linux reads .bashrc.
64
+ return platform === "darwin" ? path.join(home, ".bash_profile") : path.join(home, ".bashrc");
65
+ }
66
+ if (shell === "fish") return path.join(home, ".config", "fish", "config.fish");
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * The line to append to a shell profile. It reads the token file rather than
72
+ * embedding the secret, so the token exists in exactly one place on disk.
73
+ */
74
+ export function envExportLine(profilePath, file = "$HOME/.claude/usage-token") {
75
+ if (profilePath && profilePath.endsWith("config.fish")) {
76
+ return `set -gx AGENT_RUNWAY_TOKEN (cat ${file} 2>/dev/null); # agent-runway`;
77
+ }
78
+ return `export AGENT_RUNWAY_TOKEN="$(cat ${file} 2>/dev/null)" # agent-runway`;
79
+ }
80
+
81
+ // ------------------------------------------------------------------- terminal
82
+
83
+ function ask(question) {
84
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
85
+ return new Promise((resolve) =>
86
+ rl.question(question, (answer) => {
87
+ rl.close();
88
+ resolve(answer.trim());
89
+ })
90
+ );
91
+ }
92
+
93
+ async function confirm(question, defaultYes = true) {
94
+ if (!process.stdin.isTTY) return defaultYes;
95
+ const answer = await ask(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `);
96
+ if (!answer) return defaultYes;
97
+ return /^(y|o)/i.test(answer);
98
+ }
99
+
100
+ /** Everything piped in, for `--stdin`. */
101
+ function readStdin() {
102
+ return new Promise((resolve) => {
103
+ let data = "";
104
+ process.stdin.setEncoding("utf8");
105
+ process.stdin.on("data", (chunk) => (data += chunk));
106
+ process.stdin.on("end", () => resolve(data));
107
+ process.stdin.on("error", () => resolve(data));
108
+ });
109
+ }
110
+
111
+ /** Read a secret without echoing it to the terminal or the scrollback. */
112
+ function askSecret(question) {
113
+ if (!process.stdin.isTTY) return ask(question);
114
+
115
+ return new Promise((resolve) => {
116
+ const stdin = process.stdin;
117
+ const wasRaw = stdin.isRaw;
118
+
119
+ // Terminals wrap pasted text in ESC[200~ ... ESC[201~ (bracketed paste). In
120
+ // raw mode those arrive as ordinary bytes, and only the ESC itself is below
121
+ // 32 — "[200~" is printable and lands inside the secret. Turn the mode off
122
+ // while reading, and parse escape sequences below rather than trusting it.
123
+ process.stdout.write(BRACKETED_PASTE_OFF);
124
+ process.stdout.write(question);
125
+ stdin.setRawMode(true);
126
+ stdin.resume();
127
+
128
+ let value = "";
129
+ let escape = 0; // 0 = text, 1 = saw ESC, 2 = inside a CSI sequence
130
+
131
+ const finish = (result, exitCode) => {
132
+ stdin.removeListener("data", onData);
133
+ stdin.setRawMode(wasRaw);
134
+ stdin.pause();
135
+ process.stdout.write("\n" + BRACKETED_PASTE_ON);
136
+ if (exitCode !== undefined) process.exit(exitCode);
137
+ resolve(result);
138
+ };
139
+
140
+ const onData = (chunk) => {
141
+ for (const byte of chunk) {
142
+ // Swallow a whole escape sequence, not just its first byte.
143
+ if (escape === 1) {
144
+ escape = byte === 0x5b ? 2 : 0; // 0x5b is "["
145
+ continue;
146
+ }
147
+ if (escape === 2) {
148
+ // A CSI sequence ends on a byte in 0x40-0x7e, e.g. "~" or "A".
149
+ if (byte >= 0x40 && byte <= 0x7e) escape = 0;
150
+ continue;
151
+ }
152
+ if (byte === ESCAPE) {
153
+ escape = 1;
154
+ continue;
155
+ }
156
+
157
+ if (byte === CARRIAGE_RETURN || byte === LINE_FEED || byte === CTRL_D) {
158
+ finish(value.trim());
159
+ return;
160
+ }
161
+ if (byte === CTRL_C) {
162
+ finish("", 130);
163
+ return;
164
+ }
165
+ if (byte === DELETE || byte === BACKSPACE) {
166
+ value = value.slice(0, -1);
167
+ continue;
168
+ }
169
+ if (byte < 32) continue;
170
+ value += String.fromCharCode(byte);
171
+ }
172
+ };
173
+
174
+ stdin.on("data", onData);
175
+ });
176
+ }
177
+
178
+ // -------------------------------------------------------------------- actions
179
+
180
+ // Spawning `claude setup-token` used to live here. It was removed once the
181
+ // endpoint answered "OAuth token does not meet scope requirement user:profile":
182
+ // that command mints inference-scoped tokens, so offering to run it only led
183
+ // users into a 403 they could not fix by trying again.
184
+
185
+ /** Ask the API whether the token actually works. Shape checks are not enough. */
186
+ async function validateToken(token) {
187
+ try {
188
+ const usage = await fetchUsage({
189
+ env: { AGENT_RUNWAY_TOKEN: token, AGENT_RUNWAY_NO_LOCAL_CREDENTIALS: "1" },
190
+ });
191
+ return { ok: true, usage };
192
+ } catch (error) {
193
+ return { ok: false, error };
194
+ }
195
+ }
196
+
197
+ export function persistToken(token, home = os.homedir()) {
198
+ const file = tokenPath(home);
199
+ fs.mkdirSync(path.dirname(file), { recursive: true });
200
+ // mode is honoured on POSIX and ignored on Windows, where the user profile
201
+ // directory is already ACL-restricted to the account.
202
+ fs.writeFileSync(file, token + "\n", { encoding: "utf8", mode: 0o600 });
203
+ try {
204
+ fs.chmodSync(file, 0o600);
205
+ } catch {
206
+ /* Windows: no-op */
207
+ }
208
+ return file;
209
+ }
210
+
211
+ /** Windows: pass the value on stdin so it never appears in a process list. */
212
+ function setWindowsUserEnv(name, value) {
213
+ return new Promise((resolve) => {
214
+ const script = `[Environment]::SetEnvironmentVariable('${name}', [Console]::In.ReadLine(), 'User')`;
215
+ const child = spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
216
+ stdio: ["pipe", "ignore", "ignore"],
217
+ });
218
+ child.on("error", () => resolve(false));
219
+ child.on("close", (code) => resolve(code === 0));
220
+ child.stdin.write(value + "\n");
221
+ child.stdin.end();
222
+ });
223
+ }
224
+
225
+ async function configureEnv(token) {
226
+ if (IS_WINDOWS) {
227
+ out("");
228
+ out(" Note: on Windows the variable holds a second copy of the token.");
229
+ out(" The token file alone is already picked up automatically.");
230
+ if (!(await confirm(" Set AGENT_RUNWAY_TOKEN for your user account anyway?", false))) {
231
+ return "skipped";
232
+ }
233
+ const ok = await setWindowsUserEnv("AGENT_RUNWAY_TOKEN", token);
234
+ out(ok
235
+ ? " Set. Open a new terminal for it to take effect."
236
+ : " Could not set it; the token file still works.");
237
+ return ok ? "windows-env" : "failed";
238
+ }
239
+
240
+ const profile = shellProfilePath();
241
+ if (!profile) {
242
+ out("");
243
+ out(" Unknown shell, so nothing was edited. Add this line yourself if you want it:");
244
+ out(` ${envExportLine(null)}`);
245
+ return "manual";
246
+ }
247
+
248
+ let existing = "";
249
+ try {
250
+ existing = fs.readFileSync(profile, "utf8");
251
+ } catch {
252
+ /* the profile may not exist yet */
253
+ }
254
+ if (existing.includes("# agent-runway")) {
255
+ out(` ${profile} already has the line; left untouched.`);
256
+ return "already";
257
+ }
258
+
259
+ const line = envExportLine(profile);
260
+ out("");
261
+ out(` Append to ${profile}:`);
262
+ out(` ${line}`);
263
+ out(" It reads the token file rather than storing a second copy.");
264
+ if (!(await confirm(" Append it?", true))) return "skipped";
265
+
266
+ fs.appendFileSync(profile, "\n" + line + "\n", "utf8");
267
+ out(` Done. Run 'source ${profile}' or open a new terminal.`);
268
+ return "posix-profile";
269
+ }
270
+
271
+ function printNextSteps() {
272
+ out("");
273
+ out("Next steps");
274
+ out("");
275
+ out(" Check usage any time:");
276
+ out(" agent-runway");
277
+ out("");
278
+ out(" Use it from Claude Code (skill + MCP tool):");
279
+ out(" /plugin marketplace add jberdah/agent-runway");
280
+ out(" /plugin install agent-runway@agent-runway");
281
+ out("");
282
+ out(" Use it as an MCP tool in another client:");
283
+ out(` node ${path.join(PACKAGE_ROOT, "src", "mcp.mjs")}`);
284
+ }
285
+
286
+ // ----------------------------------------------------------------------- main
287
+
288
+ export async function setup(argv = []) {
289
+ const wantsEnv = argv.includes("--env");
290
+ const force = argv.includes("--force");
291
+
292
+ out("");
293
+ out(`agent-runway ${VERSION} - setup`);
294
+ out("");
295
+
296
+ // 1. Is it already working?
297
+ if (!force) {
298
+ try {
299
+ const usage = await fetchUsage();
300
+ out(`Already working (token from ${usage.tokenSource}).`);
301
+ out("");
302
+ out(renderTable(usage));
303
+ out("");
304
+ out("Re-run with --force to replace the token.");
305
+ return 0;
306
+ } catch (error) {
307
+ if (!(error instanceof UsageError) || !["NO_TOKEN", "AUTH"].includes(error.code)) throw error;
308
+ out(error.code === "AUTH"
309
+ ? "A token was found but the API rejected it. Let's replace it."
310
+ : "No token found yet. Let's create one.");
311
+ }
312
+ }
313
+
314
+ // 2a. Token arriving on stdin. Lets the secret go straight from whatever
315
+ // produced it into the token file, without being displayed, selected or
316
+ // pasted. Also the sane path in CI.
317
+ //
318
+ // claude setup-token | agent-runway setup --force --stdin
319
+ // echo $TOKEN | agent-runway setup --force --stdin
320
+ //
321
+ // The input is scanned rather than trusted whole, because `claude
322
+ // setup-token` prints instructions around the token.
323
+ if (argv.includes("--stdin")) {
324
+ const piped = await readStdin();
325
+ const found = piped.match(/sk-ant-\S{16,}/)?.[0] ?? null;
326
+
327
+ if (!found) {
328
+ out("No token found on stdin. Expected something containing sk-ant-...");
329
+ return 1;
330
+ }
331
+ out(`Read a ${found.length}-character token from stdin. Checking it...`);
332
+
333
+ const checked = await validateToken(found);
334
+ if (!checked.ok) {
335
+ const why = checked.error instanceof UsageError ? checked.error.message : String(checked.error);
336
+ out(` Rejected: ${why}`);
337
+ out(" Nothing saved.");
338
+ return 1;
339
+ }
340
+
341
+ out(" Accepted.");
342
+ out(` Saved to ${persistToken(found)}${IS_WINDOWS ? "" : " (mode 0600)"}.`);
343
+ out("");
344
+ out(renderTable(checked.usage));
345
+ return 0;
346
+ }
347
+
348
+ // 2b. Create one interactively. This needs a real terminal: it spawns a
349
+ // browser sign-in and reads a secret back. Piped or in CI, say so rather than
350
+ // blocking on a prompt nobody can answer.
351
+ if (!process.stdin.isTTY) {
352
+ out("");
353
+ out("Setup is interactive and there is no terminal attached.");
354
+ out("Run it from a terminal, or provide a token another way:");
355
+ out("");
356
+ out(" claude setup-token # then either");
357
+ out(" export AGENT_RUNWAY_TOKEN=sk-ant-... # env var, good for CI");
358
+ out(` echo sk-ant-... > ${tokenPath()} # or the token file`);
359
+ return 1;
360
+ }
361
+
362
+ out("");
363
+ out("Note: `claude setup-token` does NOT help here. The token it mints carries");
364
+ out("inference scopes, and the usage endpoint requires user:profile, so it is");
365
+ out("refused with a 403 no matter how many times it is regenerated.");
366
+ out("");
367
+ out("Claude usage is readable today only through Claude Code's own session");
368
+ out("credentials, so the practical answer is to keep Claude Code signed in.");
369
+ out("");
370
+ out("If you do hold a token carrying user:profile, paste it now; otherwise stop");
371
+ out("here with Ctrl-C and just sign in to Claude Code.");
372
+
373
+ // 3. Take it, validate it, and only then store it.
374
+ out("");
375
+ const token = await askSecret("Paste the token (input hidden): ");
376
+
377
+ if (!tokenLooksValid(token)) {
378
+ out("");
379
+ out(`Read ${token.length} characters, which do not start with sk-ant-. Nothing saved.`);
380
+ if (token.includes("sk-ant-")) {
381
+ // Almost always a terminal wrapping the paste in escape codes.
382
+ out("");
383
+ out("The prefix is present but not at the start, so the paste brought");
384
+ out("extra characters with it. Either type the token by hand, or hand it");
385
+ out("over without the prompt:");
386
+ out("");
387
+ out(" AGENT_RUNWAY_TOKEN=sk-ant-... agent-runway");
388
+ }
389
+ return 1;
390
+ }
391
+
392
+ out("");
393
+ out("Checking it against the API...");
394
+ const result = await validateToken(token);
395
+ if (!result.ok) {
396
+ const reason = result.error instanceof UsageError ? result.error.message : String(result.error);
397
+ out(` Rejected: ${reason}`);
398
+ out(" Nothing saved.");
399
+ return 1;
400
+ }
401
+ out(" Accepted.");
402
+
403
+ const file = persistToken(token);
404
+ out(` Saved to ${file}${IS_WINDOWS ? "" : " (mode 0600)"}.`);
405
+
406
+ if (wantsEnv) await configureEnv(token);
407
+
408
+ out("");
409
+ out(renderTable(result.usage));
410
+ printNextSteps();
411
+ return 0;
412
+ }