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/cli.mjs ADDED
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ // Command line entry point. Also what the Claude Code skill shells out to.
3
+
4
+ import { fetchUsage, UsageError, VERSION } from "./core.mjs";
5
+ import { renderModels, renderProviders, renderShort, renderTable } from "./render.mjs";
6
+
7
+ const HELP = `agent-runway ${VERSION}
8
+
9
+ Show how much runway is left before you hit a rate limit, and which models
10
+ each installed agent will actually accept.
11
+
12
+ Usage:
13
+ agent-runway [options]
14
+ agent-runway setup [--env] [--force]
15
+
16
+ Commands:
17
+ setup Guided first-time setup: create a token, check it, save it.
18
+ --env also export AGENT_RUNWAY_TOKEN from your shell profile
19
+ --force replace a token that already works
20
+
21
+ Options:
22
+ --all Every provider found on this machine, not just Claude
23
+ --models What each installed agent will accept as a model, and
24
+ where two installs of the same agent disagree
25
+ --gate <N> Decide: is there room to start work, at threshold N percent?
26
+ Prints JSON. Exit 0 proceed, 10 defer, 11 unknown.
27
+ --short One line, machine friendly: session=79% weekly_all=76%
28
+ --json Raw API response, unformatted
29
+ --plain Skip the header, print only the windows
30
+ -h, --help Show this help
31
+ -v, --version
32
+
33
+ Providers: claude, codex, copilot, antigravity. Each is read with the
34
+ credentials that provider already keeps, and one failing never stops the rest.
35
+
36
+ Authentication, first match wins:
37
+ CLAUDE_CODE_OAUTH_TOKEN token from \`claude setup-token\` (recommended)
38
+ AGENT_RUNWAY_TOKEN
39
+ ANTHROPIC_AUTH_TOKEN
40
+ ~/.claude/usage-token file containing the token on a single line
41
+ ~/.claude/.credentials.json Claude Code's own session token, often stale
42
+
43
+ Set AGENT_RUNWAY_NO_LOCAL_CREDENTIALS=1 to never read the last source.
44
+
45
+ Exit codes:
46
+ 0 success 1 error 2 no token 3 auth rejected 4 endpoint rate limited
47
+ `;
48
+
49
+ const EXIT = { NO_TOKEN: 2, AUTH: 3, RATE_LIMITED: 4 };
50
+
51
+ // A gate is a decision, so it answers in exit codes as well as on stdout, and
52
+ // it has three outcomes rather than two: a provider that cannot be read is not
53
+ // the same as one with room, and must never be treated as one.
54
+ const GATE_EXIT = { proceed: 0, defer: 10, unknown: 11 };
55
+
56
+ /** `--gate 90`, `--gate=90`, or `--gate` for the default. */
57
+ function gateThreshold(argv) {
58
+ const index = argv.findIndex((a) => a === "--gate" || a.startsWith("--gate="));
59
+ if (index === -1) return null;
60
+ const inline = argv[index].split("=")[1];
61
+ const value = Number(inline ?? argv[index + 1]);
62
+ return Number.isFinite(value) && value >= 0 && value <= 100 ? value : 90;
63
+ }
64
+
65
+ async function main(argv) {
66
+ const has = (...names) => names.some((n) => argv.includes(n));
67
+
68
+ // Loaded on demand: setup pulls in child_process and readline, which the
69
+ // common path (a single GET) has no use for.
70
+ if (argv[0] === "setup") {
71
+ const { setup } = await import("./setup.mjs");
72
+ return setup(argv.slice(1));
73
+ }
74
+
75
+ // Everything needed to invoke one agent, in one answer. Meant for a caller
76
+ // that already builds command lines and only lacks the binary and a valid
77
+ // slug — brainclaw resolves the first with a bare `where` and never checks
78
+ // the second.
79
+ if (argv[0] === "resolve") {
80
+ const agent = argv[1];
81
+ const [{ discoverInstalls }, models] = await Promise.all([
82
+ import("./installs.mjs"),
83
+ import("./models.mjs"),
84
+ ]);
85
+
86
+ if (!models.SPAWNABLE.includes(agent)) {
87
+ process.stderr.write(`agent-runway: resolve needs one of ${models.SPAWNABLE.join(", ")}\n`);
88
+ return 1;
89
+ }
90
+
91
+ const flag = (name) => {
92
+ const i = argv.findIndex((a) => a === name || a.startsWith(`${name}=`));
93
+ return i === -1 ? null : argv[i].split("=")[1] ?? argv[i + 1] ?? null;
94
+ };
95
+
96
+ // Capacity is only consulted when asked: it costs a network round trip,
97
+ // and "which slug" is often the whole question.
98
+ let capacity = null;
99
+ if (has("--with-capacity")) {
100
+ const { readAll, capacity: decide } = await import("./providers/index.mjs");
101
+ const decision = decide(await readAll({ providers: [agent] }), { threshold: 90 });
102
+ capacity = decision.providers[0] ?? null;
103
+ }
104
+
105
+ const answer = await models.resolveAgent(agent, {
106
+ installs: discoverInstalls({ withVersions: true }),
107
+ model: flag("--model"),
108
+ capacity,
109
+ });
110
+
111
+ process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
112
+ // An unresolvable agent, or a slug its binary refuses, is a failed
113
+ // precondition rather than a crash: exit 1 so a script can branch.
114
+ return answer.resolved && answer.model?.valid !== false ? 0 : 1;
115
+ }
116
+
117
+ // What each install will accept as a model. A separate question from quota,
118
+ // and the one that decides whether a delegation's -m argument is valid.
119
+ if (has("--models")) {
120
+ const [{ discoverInstalls }, models] = await Promise.all([
121
+ import("./installs.mjs"),
122
+ import("./models.mjs"),
123
+ ]);
124
+ const installs = discoverInstalls({ withVersions: true })
125
+ .filter((i) => models.SPAWNABLE.includes(i.agent));
126
+ const catalogues = await models.modelsForAll(installs);
127
+
128
+ if (has("--json")) {
129
+ process.stdout.write(`${JSON.stringify({ catalogues, skew: models.modelSkew(catalogues) }, null, 2)}\n`);
130
+ return 0;
131
+ }
132
+ process.stdout.write(`\n${renderModels(catalogues, models.modelSkew(catalogues))}\n`);
133
+ return 0;
134
+ }
135
+
136
+ // Multi-provider paths go through the registry, which isolates failures:
137
+ // Antigravity needs its IDE open, a Codex token expires, gh may be absent.
138
+ const threshold = gateThreshold(argv);
139
+ if (threshold !== null || has("--all")) {
140
+ const { readAll, capacity } = await import("./providers/index.mjs");
141
+ const results = await readAll();
142
+
143
+ if (threshold === null) {
144
+ process.stdout.write(`\n${renderProviders(results)}\n`);
145
+ return 0;
146
+ }
147
+
148
+ const decision = capacity(results, { threshold });
149
+ process.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
150
+
151
+ // The worst outcome decides: an unreadable provider outranks a comfortable
152
+ // one, because "I could not tell" must not be reported as room to work.
153
+ if (decision.providers.some((p) => p.decision === "proceed")) return GATE_EXIT.proceed;
154
+ if (decision.anyUnknown) return GATE_EXIT.unknown;
155
+ return GATE_EXIT.defer;
156
+ }
157
+
158
+ if (has("-h", "--help")) {
159
+ process.stdout.write(HELP);
160
+ return 0;
161
+ }
162
+ if (has("-v", "--version")) {
163
+ process.stdout.write(`${VERSION}\n`);
164
+ return 0;
165
+ }
166
+
167
+ const usage = await fetchUsage();
168
+
169
+ if (has("--json")) {
170
+ process.stdout.write(`${JSON.stringify(usage.raw, null, 2)}\n`);
171
+ return 0;
172
+ }
173
+ if (has("--short")) {
174
+ process.stdout.write(`${renderShort(usage)}\n`);
175
+ return 0;
176
+ }
177
+
178
+ const body = renderTable(usage);
179
+ if (has("--plain")) {
180
+ process.stdout.write(`${body}\n`);
181
+ } else {
182
+ process.stdout.write(`\nRunway - Claude\n\n${body}\n`);
183
+ }
184
+ return 0;
185
+ }
186
+
187
+ main(process.argv.slice(2))
188
+ .then((code) => {
189
+ process.exitCode = code;
190
+ })
191
+ .catch((error) => {
192
+ if (error instanceof UsageError) {
193
+ process.stderr.write(`agent-runway: ${error.message}\n`);
194
+ if (error.hint) process.stderr.write(`\n${error.hint}\n`);
195
+ process.exitCode = EXIT[error.code] ?? 1;
196
+ return;
197
+ }
198
+ process.stderr.write(`agent-runway: unexpected error: ${error?.message ?? error}\n`);
199
+ process.exitCode = 1;
200
+ });
package/src/core.mjs ADDED
@@ -0,0 +1,300 @@
1
+ // Core logic shared by the CLI, the Claude Code skill and the MCP server.
2
+ //
3
+ // Security invariant: this module never logs, prints or returns a token.
4
+ // Callers receive usage data only. Keep it that way.
5
+
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+
10
+ export const VERSION = "0.2.0";
11
+
12
+ const USER_AGENT = `agent-runway/${VERSION} (+https://github.com/jberdah/agent-runway)`;
13
+
14
+ /** Errors this module throws, with a stable `code` so callers can branch. */
15
+ export class UsageError extends Error {
16
+ constructor(code, message, hint) {
17
+ super(message);
18
+ this.name = "UsageError";
19
+ this.code = code; // NO_TOKEN | AUTH | RATE_LIMITED | NO_RESPONSE | NETWORK
20
+ this.hint = hint;
21
+ }
22
+ }
23
+
24
+ const claudeDir = () => path.join(os.homedir(), ".claude");
25
+
26
+ function readCredentialsFile() {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(path.join(claudeDir(), ".credentials.json"), "utf8"));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function readTokenFile(file) {
35
+ try {
36
+ const value = fs.readFileSync(file, "utf8").trim();
37
+ return value.startsWith("sk-ant-") ? value : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Resolve an OAuth token, most explicit source first.
45
+ *
46
+ * The last source is the token Claude Code keeps for its own session. It is a
47
+ * convenience so the tool works with no setup, but it is not a stable contract:
48
+ * on macOS the live token lives in the Keychain and on Windows in the Credential
49
+ * Manager, so the file is frequently absent or stale. Set
50
+ * AGENT_RUNWAY_NO_LOCAL_CREDENTIALS=1 to skip it entirely.
51
+ *
52
+ * @returns {{token: string, source: string, expiredAt: string|null}|null}
53
+ */
54
+ export function resolveToken(env = process.env) {
55
+ const fromEnv = [
56
+ ["CLAUDE_CODE_OAUTH_TOKEN", "env CLAUDE_CODE_OAUTH_TOKEN"],
57
+ ["AGENT_RUNWAY_TOKEN", "env AGENT_RUNWAY_TOKEN"],
58
+ ["ANTHROPIC_AUTH_TOKEN", "env ANTHROPIC_AUTH_TOKEN"],
59
+ ];
60
+ for (const [name, source] of fromEnv) {
61
+ if (env[name]) return { token: env[name], source, expiredAt: null };
62
+ }
63
+
64
+ const fileToken = readTokenFile(path.join(claudeDir(), "usage-token"));
65
+ if (fileToken) return { token: fileToken, source: "~/.claude/usage-token", expiredAt: null };
66
+
67
+ if (env.AGENT_RUNWAY_NO_LOCAL_CREDENTIALS === "1") return null;
68
+
69
+ const oauth = readCredentialsFile()?.claudeAiOauth;
70
+ if (oauth?.accessToken) {
71
+ const expired = oauth.expiresAt && oauth.expiresAt < Date.now();
72
+ return {
73
+ token: oauth.accessToken,
74
+ source: "~/.claude/.credentials.json",
75
+ expiredAt: expired ? new Date(oauth.expiresAt).toISOString() : null,
76
+ };
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * The claude.ai web session cookie, for the organizations endpoint.
83
+ *
84
+ * A separate credential from the OAuth token, and the only one that endpoint
85
+ * takes: it answers "This endpoint does not accept OAuth access tokens" to a
86
+ * Bearer, whatever its scopes. Supply the value of the `sessionKey` cookie.
87
+ *
88
+ * Never harvested from a browser profile — the user pastes it, or does not.
89
+ */
90
+ export function resolveCookie(env = process.env) {
91
+ if (env.AGENT_RUNWAY_CLAUDE_COOKIE) {
92
+ return { value: env.AGENT_RUNWAY_CLAUDE_COOKIE, source: "env AGENT_RUNWAY_CLAUDE_COOKIE" };
93
+ }
94
+ try {
95
+ const value = fs.readFileSync(path.join(claudeDir(), "session-cookie"), "utf8").trim();
96
+ if (value) return { value, source: "~/.claude/session-cookie" };
97
+ } catch {
98
+ /* absent is the normal case */
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /** Organization UUID, needed only by the claude.ai endpoint. */
104
+ export function resolveOrgId(env = process.env) {
105
+ return env.CLAUDE_ORG_ID || readCredentialsFile()?.organizationUuid || null;
106
+ }
107
+
108
+ async function request(url, authHeaders, fetchImpl) {
109
+ let response;
110
+ try {
111
+ response = await fetchImpl(url, {
112
+ headers: {
113
+ ...authHeaders,
114
+ Accept: "application/json",
115
+ "anthropic-beta": "oauth-2025-04-20",
116
+ "User-Agent": USER_AGENT,
117
+ },
118
+ });
119
+ } catch (cause) {
120
+ throw new UsageError("NETWORK", `Could not reach ${new URL(url).host}: ${cause.message}`);
121
+ }
122
+ const text = await response.text();
123
+ let body;
124
+ try {
125
+ body = JSON.parse(text);
126
+ } catch {
127
+ body = text;
128
+ }
129
+ return { status: response.status, ok: response.ok, body };
130
+ }
131
+
132
+ const WINDOW_LABELS = {
133
+ session: "Session (5h)",
134
+ weekly_all: "Weekly - all models",
135
+ weekly_scoped: "Weekly",
136
+ };
137
+
138
+ /**
139
+ * Flatten the API payload into a shape the three surfaces can share.
140
+ *
141
+ * The endpoints are internal and undocumented, so the canonical `limits` array
142
+ * is used when present and a tolerant scan of the top-level window objects is
143
+ * the fallback. Neither path is allowed to throw on an unexpected shape.
144
+ */
145
+ export function normalize(payload) {
146
+ const windows = [];
147
+
148
+ if (Array.isArray(payload?.limits) && payload.limits.length) {
149
+ for (const limit of payload.limits) {
150
+ const model = limit.scope?.model?.display_name ?? null;
151
+ const base = WINDOW_LABELS[limit.kind] ?? limit.kind;
152
+ windows.push({
153
+ id: limit.kind,
154
+ label: model ? `${base} - ${model}` : base,
155
+ percent: limit.percent,
156
+ severity: limit.severity ?? "normal",
157
+ resetsAt: limit.resets_at ?? null,
158
+ active: Boolean(limit.is_active),
159
+ model,
160
+ });
161
+ }
162
+ } else if (payload && typeof payload === "object") {
163
+ for (const [key, value] of Object.entries(payload)) {
164
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
165
+ if (typeof value.utilization !== "number") continue;
166
+ const percent = value.utilization > 0 && value.utilization <= 1
167
+ ? value.utilization * 100
168
+ : value.utilization;
169
+ windows.push({
170
+ id: key,
171
+ label: WINDOW_LABELS[key] ?? key,
172
+ percent: Math.round(percent),
173
+ severity: "normal",
174
+ resetsAt: value.resets_at ?? null,
175
+ active: false,
176
+ model: null,
177
+ });
178
+ }
179
+ }
180
+
181
+ const order = { session: 0, weekly_all: 1, weekly_scoped: 2 };
182
+ windows.sort((a, b) => (order[a.id] ?? 9) - (order[b.id] ?? 9) || b.percent - a.percent);
183
+
184
+ const extra = payload?.extra_usage;
185
+ const extraUsage = extra && typeof extra === "object"
186
+ ? {
187
+ enabled: Boolean(extra.is_enabled),
188
+ percent: typeof extra.utilization === "number"
189
+ ? Math.round(extra.utilization > 1 ? extra.utilization : extra.utilization * 100)
190
+ : null,
191
+ monthlyLimit: extra.monthly_limit ?? null,
192
+ currency: extra.currency ?? null,
193
+ spendLimitReached: Boolean(extra.spend_limit_reached),
194
+ }
195
+ : null;
196
+
197
+ return { windows, extraUsage };
198
+ }
199
+
200
+ /**
201
+ * Fetch usage for the current account.
202
+ *
203
+ * @returns {Promise<{windows: Array, extraUsage: object|null, endpoint: string, tokenSource: string, raw: object}>}
204
+ */
205
+ export async function fetchUsage({ env = process.env, fetchImpl = globalThis.fetch } = {}) {
206
+ const resolved = resolveToken(env);
207
+ if (!resolved) {
208
+ throw new UsageError(
209
+ "NO_TOKEN",
210
+ "No Claude OAuth token found.",
211
+ "Run `claude setup-token` and export the result as CLAUDE_CODE_OAUTH_TOKEN."
212
+ );
213
+ }
214
+
215
+ const orgId = resolveOrgId(env);
216
+ const cookie = resolveCookie(env);
217
+
218
+ // Each endpoint takes exactly one kind of credential. Sending a Bearer to
219
+ // claude.ai is not a fallback, it is a guaranteed 403 that only adds a
220
+ // confusing second line to every failure, so that endpoint is offered only
221
+ // when a session cookie exists to authenticate it.
222
+ const endpoints = [
223
+ {
224
+ name: "oauth/usage",
225
+ url: "https://api.anthropic.com/api/oauth/usage",
226
+ headers: { Authorization: `Bearer ${resolved.token}` },
227
+ },
228
+ ];
229
+ if (orgId && cookie) {
230
+ endpoints.push({
231
+ name: "organizations/usage",
232
+ url: `https://claude.ai/api/organizations/${orgId}/usage`,
233
+ headers: { Cookie: `sessionKey=${cookie.value}` },
234
+ });
235
+ }
236
+
237
+ const failures = [];
238
+ for (const endpoint of endpoints) {
239
+ let result;
240
+ try {
241
+ result = await request(endpoint.url, endpoint.headers, fetchImpl);
242
+ } catch (error) {
243
+ // A fallback that cannot be reached must not bury what the primary
244
+ // endpoint already answered. Record it and keep going; the verdict is
245
+ // decided once every endpoint has had its turn.
246
+ failures.push(`${endpoint.name}: ${error.message}`);
247
+ continue;
248
+ }
249
+
250
+ if (result.ok) {
251
+ return {
252
+ ...normalize(result.body),
253
+ endpoint: endpoint.name,
254
+ tokenSource: resolved.source,
255
+ raw: result.body,
256
+ };
257
+ }
258
+
259
+ // Carry the API's own words through. A rejection that says which scope is
260
+ // missing is worth far more than a bare "rejected", and this endpoint is
261
+ // undocumented enough that its reasons are the only source available.
262
+ const apiMessage =
263
+ (result.body && typeof result.body === "object"
264
+ ? result.body.error?.message ?? result.body.message ?? result.body.error?.type
265
+ : null) || null;
266
+ failures.push(`${endpoint.name}: HTTP ${result.status}${apiMessage ? ` - ${apiMessage}` : ""}`);
267
+
268
+ if (result.status === 429) {
269
+ throw new UsageError(
270
+ "RATE_LIMITED",
271
+ "The usage endpoint itself is rate limiting these requests. This is not your account quota.",
272
+ "Wait a few minutes before retrying. Do not poll in a loop."
273
+ );
274
+ }
275
+ }
276
+
277
+ const authFailed = failures.some((f) => /HTTP 40[13]/.test(f));
278
+ if (authFailed) {
279
+ const stale = resolved.expiredAt ? ` The token expired at ${resolved.expiredAt}.` : "";
280
+ const scopeProblem = failures.some((f) => /scope|permission/i.test(f));
281
+ throw new UsageError(
282
+ "AUTH",
283
+ `Token rejected (source: ${resolved.source}).${stale}\n ` + failures.join("\n "),
284
+ scopeProblem
285
+ ? "This token is valid, but not for reading usage. The endpoint requires the\n" +
286
+ "user:profile scope, and `claude setup-token` does not grant it - regenerating\n" +
287
+ "the token produces exactly the same refusal.\n\n" +
288
+ "Claude usage can only be read with Claude Code's own session credentials\n" +
289
+ "today, which means keeping Claude Code signed in. Codex, Copilot and\n" +
290
+ "Antigravity are unaffected: they have durable credentials of their own."
291
+ : "Sign in to Claude Code, or supply a token carrying the user:profile scope."
292
+ );
293
+ }
294
+ const reachable = failures.some((f) => /HTTP \d/.test(f));
295
+ throw new UsageError(
296
+ "NO_RESPONSE",
297
+ `No usage endpoint responded (${failures.join(", ")}).`,
298
+ reachable ? undefined : "Every endpoint failed to connect. Check the network or a proxy."
299
+ );
300
+ }