offrouter-cli 0.1.0 → 0.2.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.
@@ -1,33 +1,413 @@
1
+ /**
2
+ * `offrouter login` command: real OAuth PKCE login with browser open.
3
+ *
4
+ * Supports anthropic and openai (bring-your-own-client-id, documented as fragile).
5
+ * Tokens are stored in the OffRouter secret store (keychain or file fallback).
6
+ * Never scrapes or copies credentials from other CLIs.
7
+ */
1
8
  import type { Command } from "commander";
2
- import { V1_HONESTY_LINE } from "./detection.js";
9
+ import { execSync } from "node:child_process";
10
+ import { join } from "node:path";
11
+ import {
12
+ FileSecretStore,
13
+ PKCE_CONFIGS,
14
+ getOAuthTokens,
15
+ isOAuthProvider,
16
+ isOAuthTokenExpired,
17
+ refreshTokens,
18
+ resolveClientId,
19
+ resolveOffRouterHome,
20
+ runOAuthLogin,
21
+ } from "offrouter-core";
22
+ import type { OAuthStoredTokens } from "offrouter-core";
3
23
  import { writeJson, writeText } from "./output.js";
4
24
  import type { CommandContext } from "./init.js";
5
25
 
26
+ // ---------------------------------------------------------------------------
27
+ // Browser open (macOS, best-effort)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function openBrowser(url: string): boolean {
31
+ try {
32
+ execSync(`open "${url}"`, { timeout: 5000, stdio: "ignore" });
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Secret store helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function createStore(env: NodeJS.ProcessEnv): FileSecretStore {
44
+ const home = resolveOffRouterHome({ env });
45
+ return new FileSecretStore({ filePath: join(home, "secrets.json") });
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Provider display helpers
50
+ // ---------------------------------------------------------------------------
51
+
52
+ interface OAuthProviderInfo {
53
+ id: string;
54
+ clientId: string | null;
55
+ tokensStored: boolean;
56
+ hasRefreshToken: boolean;
57
+ expired: boolean | null;
58
+ }
59
+
60
+ async function listOAuthProviders(
61
+ env: NodeJS.ProcessEnv,
62
+ ): Promise<OAuthProviderInfo[]> {
63
+ const providers = Object.keys(PKCE_CONFIGS);
64
+ const store = createStore(env);
65
+ const results: OAuthProviderInfo[] = [];
66
+
67
+ for (const id of providers) {
68
+ const clientId = resolveClientId(id, env) ?? null;
69
+ let tokens: OAuthStoredTokens | undefined;
70
+ try {
71
+ tokens = await getOAuthTokens(id, store);
72
+ } catch {
73
+ tokens = undefined;
74
+ }
75
+
76
+ results.push({
77
+ id,
78
+ clientId,
79
+ tokensStored: !!tokens,
80
+ hasRefreshToken: !!tokens?.refreshToken,
81
+ expired: tokens ? isOAuthTokenExpired(tokens) : null,
82
+ });
83
+ }
84
+
85
+ return results;
86
+ }
87
+
88
+ async function statusForProvider(
89
+ providerId: string,
90
+ env: NodeJS.ProcessEnv,
91
+ ): Promise<{
92
+ provider: string;
93
+ valid: boolean;
94
+ expired: boolean;
95
+ hasRefreshToken: boolean;
96
+ refreshed: boolean;
97
+ error?: string;
98
+ }> {
99
+ const store = createStore(env);
100
+ const tokens = await getOAuthTokens(providerId, store);
101
+
102
+ if (!tokens) {
103
+ return {
104
+ provider: providerId,
105
+ valid: false,
106
+ expired: false,
107
+ hasRefreshToken: false,
108
+ refreshed: false,
109
+ error: "No OAuth tokens stored. Run `offrouter login <provider>` first.",
110
+ };
111
+ }
112
+
113
+ const expired = isOAuthTokenExpired(tokens);
114
+
115
+ if (!expired) {
116
+ return {
117
+ provider: providerId,
118
+ valid: true,
119
+ expired: false,
120
+ hasRefreshToken: !!tokens.refreshToken,
121
+ refreshed: false,
122
+ };
123
+ }
124
+
125
+ // Expired: try refresh if we have a refresh token.
126
+ if (!tokens.refreshToken) {
127
+ return {
128
+ provider: providerId,
129
+ valid: false,
130
+ expired: true,
131
+ hasRefreshToken: false,
132
+ refreshed: false,
133
+ error:
134
+ "Token is expired and no refresh token is available. Run `offrouter login` again.",
135
+ };
136
+ }
137
+
138
+ try {
139
+ await refreshTokens(providerId, tokens.refreshToken, store);
140
+ return {
141
+ provider: providerId,
142
+ valid: true,
143
+ expired: false,
144
+ hasRefreshToken: true,
145
+ refreshed: true,
146
+ };
147
+ } catch (err) {
148
+ return {
149
+ provider: providerId,
150
+ valid: false,
151
+ expired: true,
152
+ hasRefreshToken: true,
153
+ refreshed: false,
154
+ error: err instanceof Error ? err.message : "Token refresh failed",
155
+ };
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Command registration
161
+ // ---------------------------------------------------------------------------
162
+
6
163
  export function registerLoginCommand(
7
164
  program: Command,
8
165
  ctx: () => CommandContext,
9
166
  ): void {
10
- program
167
+ const cmd = program
11
168
  .command("login")
12
169
  .description(
13
- "Guide official provider login / token-helper flows (no credential scraping)",
170
+ "Authenticate with an OAuth provider (Anthropic or OpenAI). Tokens stored in the OffRouter secret store.",
14
171
  )
15
- .argument("[provider]", "Provider id (optional)")
172
+ .argument("[provider]", "Provider id (anthropic, openai)")
173
+ .option("--list", "List providers with stored tokens")
174
+ .option("--status <provider>", "Check token status for a provider")
16
175
  .option("--json", "Emit JSON", false)
17
- .action(async (provider: string | undefined, opts: { json?: boolean }) => {
18
- const { stdout } = ctx();
19
- const payload = {
20
- honesty: V1_HONESTY_LINE,
21
- provider: provider ?? null,
22
- status: "not_implemented",
23
- message:
24
- "Login uses official provider login, token-helper, OAuth, or user-provided API key surfaces only. Provider adapters land in a later milestone.",
25
- };
26
- if (opts.json) {
27
- writeJson(stdout, payload);
176
+ .option("--timeout <ms>", "OAuth callback timeout in milliseconds")
177
+ .option(
178
+ "--client-id <id>",
179
+ "Override client ID (default from OFFROUTER_*_CLIENT_ID env)",
180
+ );
181
+
182
+ cmd.action(
183
+ async (
184
+ provider: string | undefined,
185
+ opts: {
186
+ json?: boolean;
187
+ list?: boolean;
188
+ status?: string;
189
+ timeout?: string;
190
+ clientId?: string;
191
+ },
192
+ ) => {
193
+ const { env, stdout } = ctx();
194
+
195
+ // --list
196
+ if (opts.list) {
197
+ await handleList(stdout, env, !!opts.json);
198
+ return;
199
+ }
200
+
201
+ // --status <provider>
202
+ if (opts.status) {
203
+ await handleStatus(stdout, env, opts.status, !!opts.json);
28
204
  return;
29
205
  }
30
- writeText(stdout, V1_HONESTY_LINE);
31
- writeText(stdout, payload.message);
206
+
207
+ // login <provider> (full flow)
208
+ if (!provider) {
209
+ if (opts.json) {
210
+ writeJson(stdout, {
211
+ error: "Missing provider argument. Run `offrouter login --help` for usage.",
212
+ });
213
+ } else {
214
+ writeText(
215
+ stdout,
216
+ "Missing provider. Usage: offrouter login <provider> [options]",
217
+ );
218
+ }
219
+ return;
220
+ }
221
+
222
+ await handleLogin(stdout, env, provider, opts);
223
+ },
224
+ );
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Handlers
229
+ // ---------------------------------------------------------------------------
230
+
231
+ async function handleList(
232
+ stdout: { write(chunk: string): boolean | void },
233
+ env: NodeJS.ProcessEnv,
234
+ json: boolean,
235
+ ): Promise<void> {
236
+ const providers = await listOAuthProviders(env);
237
+
238
+ if (json) {
239
+ writeJson(stdout, { providers });
240
+ return;
241
+ }
242
+
243
+ if (providers.length === 0) {
244
+ writeText(stdout, "No OAuth providers configured.");
245
+ return;
246
+ }
247
+
248
+ writeText(stdout, "OAuth providers:");
249
+ for (const p of providers) {
250
+ const tokenStatus = p.tokensStored
251
+ ? p.expired
252
+ ? "expired"
253
+ : "active"
254
+ : "no tokens";
255
+ const refreshStatus = p.hasRefreshToken ? " (refresh token)" : "";
256
+ const clientStatus = p.clientId ? `client-id: ${p.clientId}` : "no client-id configured";
257
+ writeText(stdout, ` ${p.id}: ${tokenStatus}${refreshStatus}, ${clientStatus}`);
258
+ }
259
+ }
260
+
261
+ async function handleStatus(
262
+ stdout: { write(chunk: string): boolean | void },
263
+ env: NodeJS.ProcessEnv,
264
+ providerId: string,
265
+ json: boolean,
266
+ ): Promise<void> {
267
+ if (!isOAuthProvider(providerId)) {
268
+ if (json) {
269
+ writeJson(stdout, {
270
+ error: `OAuth not supported for provider: ${providerId}`,
271
+ });
272
+ } else {
273
+ writeText(
274
+ stdout,
275
+ `OAuth not supported for provider "${providerId}". Supported providers: ${Object.keys(PKCE_CONFIGS).join(", ")}`,
276
+ );
277
+ }
278
+ return;
279
+ }
280
+
281
+ try {
282
+ const status = await statusForProvider(providerId, env);
283
+
284
+ if (json) {
285
+ writeJson(stdout, status);
286
+ return;
287
+ }
288
+
289
+ if (status.error) {
290
+ writeText(stdout, ` ${providerId}: ${status.error}`);
291
+ return;
292
+ }
293
+
294
+ if (status.refreshed) {
295
+ writeText(stdout, ` ${providerId}: token was expired, refreshed successfully`);
296
+ } else {
297
+ writeText(stdout, ` ${providerId}: token valid`);
298
+ }
299
+ } catch (err) {
300
+ const msg = err instanceof Error ? err.message : String(err);
301
+ if (json) {
302
+ writeJson(stdout, { error: msg, provider: providerId });
303
+ } else {
304
+ writeText(stdout, ` ${providerId}: error checking status: ${msg}`);
305
+ }
306
+ }
307
+ }
308
+
309
+ async function handleLogin(
310
+ stdout: { write(chunk: string): boolean | void },
311
+ env: NodeJS.ProcessEnv,
312
+ provider: string,
313
+ opts: {
314
+ json?: boolean;
315
+ timeout?: string;
316
+ clientId?: string;
317
+ },
318
+ ): Promise<void> {
319
+ if (!isOAuthProvider(provider)) {
320
+ if (opts.json) {
321
+ writeJson(stdout, {
322
+ error: `OAuth not supported for provider: ${provider}`,
323
+ });
324
+ } else {
325
+ writeText(
326
+ stdout,
327
+ `OAuth not supported for "${provider}". Use one of: ${Object.keys(PKCE_CONFIGS).join(", ")}`,
328
+ );
329
+ }
330
+ return;
331
+ }
332
+
333
+ const config = PKCE_CONFIGS[provider];
334
+ if (!config) return; // unreachable
335
+
336
+ // Resolve client ID
337
+ const clientId = opts.clientId ?? resolveClientId(provider, env);
338
+
339
+ if (!clientId) {
340
+ const hint = config.clientIdEnvVar
341
+ ? `Set the ${config.clientIdEnvVar} environment variable.`
342
+ : "A client ID is required. Register an OAuth app with the provider.";
343
+ if (opts.json) {
344
+ writeJson(stdout, {
345
+ error: `Client ID required for ${provider} OAuth. ${hint}`,
346
+ });
347
+ } else {
348
+ writeText(stdout, `Error: Client ID required for ${provider} OAuth.`);
349
+ writeText(stdout, hint);
350
+ }
351
+ return;
352
+ }
353
+
354
+ const store = createStore(env);
355
+ const timeoutMs = opts.timeout ? parseInt(opts.timeout, 10) : 5 * 60 * 1000;
356
+
357
+ try {
358
+ const result = await runOAuthLogin({
359
+ provider,
360
+ store,
361
+ clientId,
362
+ timeoutMs,
363
+ openBrowser,
364
+ onAuthorizeUrl(url) {
365
+ writeText(stdout, "");
366
+ writeText(stdout, `Opening browser to authorize OffRouter for ${provider}...`);
367
+ writeText(stdout, "");
368
+ writeText(stdout, `If the browser does not open, visit:`);
369
+ writeText(stdout, ` ${url}`);
370
+ writeText(stdout, "");
371
+
372
+ const opened = openBrowser(url);
373
+ if (!opened) {
374
+ writeText(stdout, "(Could not open browser automatically. Copy and paste the URL above.)");
375
+ }
376
+ writeText(stdout, "Waiting for authorization...");
377
+ },
32
378
  });
379
+
380
+ if (opts.json) {
381
+ writeJson(stdout, {
382
+ provider: result.provider,
383
+ ok: true,
384
+ redirectUri: result.redirectUri,
385
+ expiresAt: result.expiresAt,
386
+ scope: result.scope,
387
+ });
388
+ } else {
389
+ writeText(stdout, "");
390
+ writeText(stdout, `✓ Login successful for ${provider}.`);
391
+ writeText(stdout, ` Tokens stored in OffRouter secret store.`);
392
+ if (result.expiresAt) {
393
+ const expiresIn = Math.round((result.expiresAt - Date.now()) / 1000 / 60);
394
+ writeText(stdout, ` Token expires in ${expiresIn} minutes.`);
395
+ }
396
+ writeText(stdout, "");
397
+ }
398
+ } catch (err) {
399
+ const msg = err instanceof Error ? err.message : String(err);
400
+
401
+ if (opts.json) {
402
+ writeJson(stdout, {
403
+ error: msg,
404
+ provider,
405
+ ok: false,
406
+ });
407
+ } else {
408
+ writeText(stdout, "");
409
+ writeText(stdout, `✗ Login failed for ${provider}: ${msg}`);
410
+ writeText(stdout, "");
411
+ }
412
+ }
33
413
  }
@@ -1,7 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
2
3
  import type { Command } from "commander";
3
4
  import {
5
+ FileUsageStore,
4
6
  loadConfig,
7
+ resolveOffRouterHome,
5
8
  routeRequest,
6
9
  type HarnessKind,
7
10
  type RouteRequest,
@@ -79,8 +82,9 @@ export function registerRouteCommand(
79
82
  const request = buildRequest(prompt, opts.profile, cwd);
80
83
 
81
84
  let policyConfig;
85
+ let config;
82
86
  try {
83
- const config = await loadConfig({ env, workspaceDir: cwd });
87
+ config = await loadConfig({ env, workspaceDir: cwd });
84
88
  policyConfig = config.policy;
85
89
  } catch (err) {
86
90
  const configError = formatConfigError(err);
@@ -117,6 +121,28 @@ export function registerRouteCommand(
117
121
  const decision = routeRequest(request, [], policyConfig);
118
122
  const setupIncomplete = decision.needsConfiguration === true;
119
123
 
124
+ // Check near-limit status for configured accounts.
125
+ let nearLimitWarning: string | null = null;
126
+ try {
127
+ const usageStore = new FileUsageStore({
128
+ filePath: join(
129
+ resolveOffRouterHome({ env }),
130
+ "state",
131
+ "usage.json",
132
+ ),
133
+ });
134
+ const nearAccounts = await usageStore.getNearLimitAccounts(
135
+ policyConfig.nearLimitThreshold,
136
+ );
137
+ if (nearAccounts.length > 0) {
138
+ nearLimitWarning = `Account ${nearAccounts[0]!.providerId}/${nearAccounts[0]!.accountId} is near its limit (${
139
+ nearAccounts[0]!.percentRemaining ?? 0
140
+ }% remaining)`;
141
+ }
142
+ } catch {
143
+ // Non-fatal: if the usage store cannot be read, skip the warning.
144
+ }
145
+
120
146
  if (opts.json) {
121
147
  writeJson(stdout, {
122
148
  ...decision,
@@ -124,6 +150,7 @@ export function registerRouteCommand(
124
150
  setupMessage: setupIncomplete
125
151
  ? "Setup incomplete: configure a subscription, local runtime, or API-key provider, and allowlist a personal profile."
126
152
  : null,
153
+ ...(nearLimitWarning !== null ? { nearLimitWarning } : {}),
127
154
  });
128
155
  return;
129
156
  }
@@ -148,6 +175,9 @@ export function registerRouteCommand(
148
175
  "Setup incomplete: configure a subscription, local runtime, or API-key provider, and allowlist a personal profile.",
149
176
  );
150
177
  }
178
+ if (nearLimitWarning !== null) {
179
+ writeText(stdout, `! ${nearLimitWarning}`);
180
+ }
151
181
  return;
152
182
  }
153
183
 
@@ -1,5 +1,10 @@
1
1
  import type { Command } from "commander";
2
- import { loadConfig } from "offrouter-core";
2
+ import { join } from "node:path";
3
+ import {
4
+ FileUsageStore,
5
+ loadConfig,
6
+ resolveOffRouterHome,
7
+ } from "offrouter-core";
3
8
  import { formatConfigError, type CliConfigError } from "./config-error.js";
4
9
  import { detectEnvironment, V1_HONESTY_LINE } from "./detection.js";
5
10
  import { writeJson, writeText } from "./output.js";
@@ -27,12 +32,19 @@ export function registerStatusCommand(
27
32
  localReady: boolean;
28
33
  limitStatus: string;
29
34
  lastError: string | null;
30
- accounts: { accountId: string; label: string; quotaRemaining: null; nearLimit: null }[];
35
+ accounts: { accountId: string; label: string; quotaRemaining: number | null; nearLimit: boolean | null }[];
31
36
  }> = [];
32
37
  let configError: CliConfigError | null = null;
33
38
 
34
39
  try {
35
40
  const config = await loadConfig({ env, workspaceDir: cwd });
41
+ const usageStore = new FileUsageStore({
42
+ filePath: join(resolveOffRouterHome({ env }), "state", "usage.json"),
43
+ });
44
+ const allUsage = await usageStore.listAccountUsage();
45
+ const usageByKey = new Map(
46
+ allUsage.map((u) => [`${u.providerId}:${u.accountId}`, u]),
47
+ );
36
48
  providers = Object.values(config.providers)
37
49
  .map((p) => ({
38
50
  id: p.id,
@@ -42,19 +54,24 @@ export function registerStatusCommand(
42
54
  ? (["subscription"] as const)
43
55
  : []),
44
56
  ...(detection.authTiers.local ? (["local"] as const) : []),
45
- ...(detection.authTiers["api-key"] ? (["api-key"] as const) : []),
57
+ ...(detection.authTiers["api-key"]
58
+ ? (["api-key"] as const)
59
+ : []),
46
60
  ],
47
61
  subscriptionStatus: detection.subscription.status,
48
62
  apiKeyReady: detection.apiKeyReadiness.anyReady,
49
63
  localReady: detection.localRuntime.health !== "unconfigured",
50
64
  limitStatus: detection.limits.status,
51
65
  lastError: detection.lastProviderError,
52
- accounts: p.accounts.map((a) => ({
53
- accountId: a.id,
54
- label: a.label ?? a.id,
55
- quotaRemaining: null as null,
56
- nearLimit: null as null,
57
- })),
66
+ accounts: p.accounts.map((a) => {
67
+ const snap = usageByKey.get(`${p.id}:${a.id}`);
68
+ return {
69
+ accountId: a.id,
70
+ label: a.label ?? a.id,
71
+ quotaRemaining: snap?.remaining ?? null,
72
+ nearLimit: snap?.nearLimit ?? null,
73
+ };
74
+ }),
58
75
  }))
59
76
  .sort((a, b) => a.id.localeCompare(b.id));
60
77
  } catch (err) {