offrouter-cli 0.0.0 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offrouter-cli",
3
- "version": "0.0.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "offrouter": "dist/index.js"
@@ -12,13 +12,13 @@
12
12
  "test": "vitest run"
13
13
  },
14
14
  "dependencies": {
15
- "offrouter-adapter-claude": "*",
16
- "offrouter-adapter-codex": "*",
17
- "offrouter-adapter-gemini": "*",
18
- "offrouter-adapter-grok": "*",
19
- "offrouter-core": "*",
20
- "offrouter-mcp": "*",
21
- "commander": "^12.0.0",
22
- "zod": "^3.23.0"
15
+ "offrouter-adapter-claude": "0.0.0",
16
+ "offrouter-adapter-codex": "0.0.0",
17
+ "offrouter-adapter-gemini": "0.0.0",
18
+ "offrouter-adapter-grok": "0.0.0",
19
+ "offrouter-core": "0.0.0",
20
+ "offrouter-mcp": "0.0.0",
21
+ "commander": "0.0.0",
22
+ "zod": "0.0.0"
23
23
  }
24
24
  }
package/src/cli.test.ts CHANGED
@@ -3,6 +3,7 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { FileUsageStore } from "offrouter-core";
6
7
  import { createProgram, runCli } from "./index.js";
7
8
 
8
9
  async function capture(
@@ -1574,4 +1575,131 @@ describe("offrouter CLI", () => {
1574
1575
  const program = createProgram();
1575
1576
  expect(program.name()).toBe("offrouter");
1576
1577
  });
1578
+
1579
+ it("offrouter status --providers --json shows real quotaRemaining from FileUsageStore", async () => {
1580
+ const home = await mkdtemp(join(tmpdir(), "offrouter-cli-status-usage-"));
1581
+ try {
1582
+ await writeFile(
1583
+ join(home, "config.toml"),
1584
+ [
1585
+ 'allowlisted_profiles = ["claude-personal"]',
1586
+ "",
1587
+ "[providers.anthropic]",
1588
+ "enabled = true",
1589
+ 'accounts = [{ id = "my-account" }]',
1590
+ "",
1591
+ ].join("\n"),
1592
+ "utf8",
1593
+ );
1594
+ const usageStore = new FileUsageStore({
1595
+ filePath: join(home, "state", "usage.json"),
1596
+ });
1597
+ await usageStore.recordUsage("anthropic", "my-account", {
1598
+ used: 85,
1599
+ limit: 100,
1600
+ });
1601
+
1602
+ const { code, stdout } = await capture(
1603
+ ["status", "--providers", "--json"],
1604
+ { ...process.env, OFFROUTER_HOME: home },
1605
+ );
1606
+ expect(code).toBe(0);
1607
+ const json = JSON.parse(stdout) as {
1608
+ providers: Array<{ accounts: Array<{ quotaRemaining: number | null }> }>;
1609
+ };
1610
+ const account = json.providers?.[0]?.accounts?.[0];
1611
+ expect(account).toBeDefined();
1612
+ expect(account!.quotaRemaining).toBe(15);
1613
+ } finally {
1614
+ await rm(home, { recursive: true, force: true });
1615
+ }
1616
+ });
1617
+
1618
+ it("offrouter doctor --json includes nearLimitWarnings when accounts are near-limit", async () => {
1619
+ const home = await mkdtemp(join(tmpdir(), "offrouter-cli-doctor-near-"));
1620
+ try {
1621
+ await writeFile(
1622
+ join(home, "config.toml"),
1623
+ [
1624
+ 'allowlisted_profiles = ["claude-personal"]',
1625
+ "",
1626
+ "[providers.anthropic]",
1627
+ "enabled = true",
1628
+ 'accounts = [{ id = "my-account" }]',
1629
+ "",
1630
+ ].join("\n"),
1631
+ "utf8",
1632
+ );
1633
+ const usageStore = new FileUsageStore({
1634
+ filePath: join(home, "state", "usage.json"),
1635
+ });
1636
+ await usageStore.recordUsage("anthropic", "my-account", {
1637
+ used: 90,
1638
+ limit: 100,
1639
+ });
1640
+
1641
+ const { code, stdout } = await capture(
1642
+ ["doctor", "--profile", "claude-personal", "--json"],
1643
+ { ...process.env, OFFROUTER_HOME: home },
1644
+ );
1645
+ expect(code).toBe(0);
1646
+ const json = JSON.parse(stdout) as {
1647
+ nearLimitWarnings?: string[];
1648
+ accounts: Array<{ nearLimit: boolean | null }>;
1649
+ };
1650
+ expect(json.nearLimitWarnings).toBeDefined();
1651
+ expect(json.nearLimitWarnings!.length).toBeGreaterThan(0);
1652
+ expect(json.nearLimitWarnings![0]).toMatch(/near its limit/i);
1653
+ expect(json.accounts?.[0]?.nearLimit).toBe(true);
1654
+ } finally {
1655
+ await rm(home, { recursive: true, force: true });
1656
+ }
1657
+ });
1658
+
1659
+ it("offrouter route explain --json includes nearLimitWarning when an account is near-limit", async () => {
1660
+ const home = await mkdtemp(join(tmpdir(), "offrouter-cli-route-near-"));
1661
+ try {
1662
+ await writeFile(
1663
+ join(home, "config.toml"),
1664
+ [
1665
+ 'allowlisted_profiles = ["claude-personal"]',
1666
+ "",
1667
+ "[providers.anthropic]",
1668
+ "enabled = true",
1669
+ 'accounts = [{ id = "my-account" }]',
1670
+ "",
1671
+ ].join("\n"),
1672
+ "utf8",
1673
+ );
1674
+ const usageStore = new FileUsageStore({
1675
+ filePath: join(home, "state", "usage.json"),
1676
+ });
1677
+ await usageStore.recordUsage("anthropic", "my-account", {
1678
+ used: 95,
1679
+ limit: 100,
1680
+ });
1681
+
1682
+ const { code, stdout } = await capture(
1683
+ [
1684
+ "route",
1685
+ "explain",
1686
+ "explain this repo",
1687
+ "--profile",
1688
+ "claude-personal",
1689
+ "--json",
1690
+ ],
1691
+ { ...process.env, OFFROUTER_HOME: home },
1692
+ );
1693
+ expect(code).toBe(0);
1694
+ const json = JSON.parse(stdout) as {
1695
+ nearLimitWarning?: string;
1696
+ blocked: boolean;
1697
+ };
1698
+ // The route is blocked (no candidates), but the warning should be present.
1699
+ expect(json.nearLimitWarning).toBeDefined();
1700
+ expect(json.nearLimitWarning).toMatch(/near its limit/i);
1701
+ } finally {
1702
+ await rm(home, { recursive: true, force: true });
1703
+ }
1704
+ });
1577
1705
  });
@@ -4,8 +4,10 @@ import { delimiter } from "node:path";
4
4
  import { dirname, join, normalize } from "node:path";
5
5
  import {
6
6
  evaluatePolicy,
7
+ FileUsageStore,
7
8
  loadConfig,
8
9
  redact,
10
+ resolveOffRouterHome,
9
11
  routeRequest,
10
12
  type ConfigWarning,
11
13
  type HarnessKind,
@@ -329,6 +331,13 @@ export function registerDoctorCommand(
329
331
  };
330
332
  let configError: CliConfigError | null = null;
331
333
  let policyConfig: PolicyConfig;
334
+ let accountReadiness: {
335
+ providerId: string;
336
+ accountId: string;
337
+ label: string;
338
+ nearLimit: boolean | null;
339
+ }[] = [];
340
+ const nearLimitWarnings: string[] = [];
332
341
  try {
333
342
  const config = await loadConfig({ env, workspaceDir: cwd });
334
343
  policyConfig = config.policy;
@@ -343,6 +352,35 @@ export function registerDoctorCommand(
343
352
  warnings: config.warnings.length,
344
353
  warningDetails: redactConfigWarnings(config.warnings, env),
345
354
  };
355
+
356
+ // Read persistent usage data for near-limit detection.
357
+ const usageStore = new FileUsageStore({
358
+ filePath: join(resolveOffRouterHome({ env }), "state", "usage.json"),
359
+ });
360
+ const allUsage = await usageStore.listAccountUsage();
361
+ const usageByKey = new Map(
362
+ allUsage.map((u) => [`${u.providerId}:${u.accountId}`, u]),
363
+ );
364
+
365
+ accountReadiness = Object.values(config.providers).flatMap((p) =>
366
+ p.accounts.map((a) => {
367
+ const snap = usageByKey.get(`${p.id}:${a.id}`);
368
+ return {
369
+ providerId: p.id,
370
+ accountId: a.id,
371
+ label: a.label ?? a.id,
372
+ nearLimit: snap?.nearLimit ?? null,
373
+ };
374
+ }),
375
+ );
376
+
377
+ for (const a of accountReadiness) {
378
+ if (a.nearLimit) {
379
+ nearLimitWarnings.push(
380
+ `Account ${a.providerId}/${a.accountId} is near its limit (${a.label})`,
381
+ );
382
+ }
383
+ }
346
384
  } catch (err) {
347
385
  configError = formatConfigError(err);
348
386
  const safeConfigError = redactConfigError(configError, env);
@@ -421,6 +459,8 @@ export function registerDoctorCommand(
421
459
  lastProviderError: detection.lastProviderError,
422
460
  config: configSummary,
423
461
  configError,
462
+ accounts: accountReadiness,
463
+ ...(nearLimitWarnings.length > 0 ? { nearLimitWarnings } : {}),
424
464
  };
425
465
 
426
466
  if (opts.json) {
@@ -443,6 +483,18 @@ export function registerDoctorCommand(
443
483
  `- Config warning [${warning.path}]: ${warning.message}`,
444
484
  );
445
485
  }
486
+ writeText(
487
+ stdout,
488
+ `Accounts: ${accountReadiness.length} configured${accountReadiness.length > 0 ? "" : " (none)"}`,
489
+ );
490
+ for (const a of accountReadiness) {
491
+ writeText(stdout, ` account ${a.providerId}/${a.accountId}: ${a.label}`);
492
+ }
493
+ if (nearLimitWarnings.length > 0) {
494
+ for (const warning of nearLimitWarnings) {
495
+ writeText(stdout, ` ! ${warning}`);
496
+ }
497
+ }
446
498
  writeText(
447
499
  stdout,
448
500
  `Selected profile: ${profile.profile} - ${profile.status}`,
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Tests for the `offrouter login` command.
3
+ *
4
+ * Mocks `offrouter-core` functions so the login command runs without real OAuth.
5
+ * FileSecretStore is NOT mocked; it runs against a real temp directory.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Mock offrouter-core for login tests
14
+ // ---------------------------------------------------------------------------
15
+ const mocks = vi.hoisted(() => ({
16
+ mockRunOAuthLogin: vi.fn(),
17
+ mockGetOAuthTokens: vi.fn(),
18
+ mockIsOAuthTokenExpired: vi.fn(),
19
+ mockRefreshTokens: vi.fn(),
20
+ mockResolveClientId: vi.fn(),
21
+ mockResolveOffRouterHome: vi.fn(),
22
+ }));
23
+
24
+ // Keep FileSecretStore real but mock the core functions.
25
+ vi.mock("offrouter-core", async () => {
26
+ const actual = await vi.importActual<typeof import("offrouter-core")>("offrouter-core");
27
+ return {
28
+ ...actual,
29
+ runOAuthLogin: mocks.mockRunOAuthLogin,
30
+ getOAuthTokens: mocks.mockGetOAuthTokens,
31
+ isOAuthTokenExpired: mocks.mockIsOAuthTokenExpired,
32
+ refreshTokens: mocks.mockRefreshTokens,
33
+ resolveClientId: mocks.mockResolveClientId,
34
+ resolveOffRouterHome: mocks.mockResolveOffRouterHome,
35
+ };
36
+ });
37
+
38
+ import { createProgram } from "../index.js";
39
+
40
+ function capture(
41
+ argv: string[],
42
+ env: NodeJS.ProcessEnv = { ...process.env },
43
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
44
+ return new Promise((resolve) => {
45
+ let stdout = "";
46
+ let stderr = "";
47
+ const program = createProgram({
48
+ env,
49
+ stdout: {
50
+ write(chunk: string) {
51
+ stdout += chunk;
52
+ return true;
53
+ },
54
+ },
55
+ stderr: {
56
+ write(chunk: string) {
57
+ stderr += chunk;
58
+ return true;
59
+ },
60
+ },
61
+ });
62
+
63
+ program
64
+ .parseAsync(argv, { from: "user" })
65
+ .then(() => {
66
+ resolve({ code: 0, stdout, stderr });
67
+ })
68
+ .catch((err) => {
69
+ const code = err?.exitCode ?? 1;
70
+ resolve({ code, stdout, stderr });
71
+ });
72
+ });
73
+ }
74
+
75
+ describe("offrouter login command", () => {
76
+ let tmpHome: string;
77
+
78
+ beforeEach(() => {
79
+ vi.resetAllMocks();
80
+ tmpHome = mkdtempSync(join(tmpdir(), "offrouter-login-test-"));
81
+ mocks.mockResolveOffRouterHome.mockReturnValue(tmpHome);
82
+ });
83
+
84
+ afterEach(() => {
85
+ if (tmpHome && existsSync(tmpHome)) {
86
+ rmSync(tmpHome, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ describe("help", () => {
91
+ it("offrouter login --help shows login command", async () => {
92
+ const { code, stdout, stderr } = await capture(["login", "--help"]);
93
+ expect(code).toBe(0);
94
+ const output = stdout + stderr;
95
+ expect(output).toMatch(/login/i);
96
+ expect(output).toMatch(/provider/i);
97
+ expect(output).toMatch(/anthropic/i);
98
+ expect(output).toMatch(/openai/i);
99
+ });
100
+ });
101
+
102
+ describe("login <provider>", () => {
103
+ it("calls runOAuthLogin with the provider and stores tokens", async () => {
104
+ mocks.mockResolveClientId.mockReturnValue("test-client-id");
105
+ mocks.mockRunOAuthLogin.mockResolvedValue({
106
+ provider: "anthropic",
107
+ redirectUri: "http://127.0.0.1:1456/callback",
108
+ expiresAt: Date.now() + 3600_000,
109
+ scope: "api offline_access",
110
+ });
111
+
112
+ const { code, stdout, stderr } = await capture(
113
+ ["login", "anthropic"],
114
+ {
115
+ ...process.env,
116
+ OFFROUTER_HOME: tmpHome,
117
+ },
118
+ );
119
+
120
+ expect(code).toBe(0);
121
+ expect(stderr).toBe("");
122
+
123
+ expect(mocks.mockRunOAuthLogin).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ provider: "anthropic",
126
+ clientId: "test-client-id",
127
+ }),
128
+ );
129
+
130
+ expect(stdout).toMatch(/successful|Login/);
131
+ expect(stdout).toContain("anthropic");
132
+ });
133
+
134
+ it("prints error for unsupported provider", async () => {
135
+ const { code, stdout } = await capture(["login", "gemini"]);
136
+ expect(code).toBe(0);
137
+ expect(stdout).toMatch(/not supported/i);
138
+ expect(mocks.mockRunOAuthLogin).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("prints error when no client id is configured", async () => {
142
+ const { code, stdout } = await capture(
143
+ ["login", "anthropic"],
144
+ { ...process.env, OFFROUTER_HOME: tmpHome },
145
+ );
146
+ expect(code).toBe(0);
147
+ expect(stdout).toMatch(/client id required|client.*id/i);
148
+ expect(mocks.mockRunOAuthLogin).not.toHaveBeenCalled();
149
+ });
150
+ });
151
+
152
+ describe("login --list", () => {
153
+ it("shows providers with no tokens when none stored", async () => {
154
+ mocks.mockGetOAuthTokens.mockResolvedValue(undefined);
155
+
156
+ const { code, stdout } = await capture(
157
+ ["login", "--list"],
158
+ { ...process.env, OFFROUTER_HOME: tmpHome },
159
+ );
160
+ expect(code).toBe(0);
161
+ expect(stdout).toMatch(/OAuth providers/i);
162
+ expect(stdout).toContain("anthropic");
163
+ expect(stdout).toContain("openai");
164
+ });
165
+
166
+ it("shows active token status", async () => {
167
+ mocks.mockGetOAuthTokens.mockResolvedValue({
168
+ accessToken: "tok",
169
+ refreshToken: "refresh",
170
+ expiresAt: Date.now() + 3600_000,
171
+ obtainedAt: Date.now(),
172
+ });
173
+ mocks.mockIsOAuthTokenExpired.mockReturnValue(false);
174
+
175
+ const { code, stdout } = await capture(
176
+ ["login", "--list"],
177
+ { ...process.env, OFFROUTER_HOME: tmpHome },
178
+ );
179
+ expect(code).toBe(0);
180
+ expect(stdout).toContain("active");
181
+ });
182
+
183
+ it("shows expired token status", async () => {
184
+ mocks.mockGetOAuthTokens.mockResolvedValue({
185
+ accessToken: "tok",
186
+ refreshToken: "refresh",
187
+ expiresAt: Date.now() - 1000,
188
+ obtainedAt: Date.now() - 7200_000,
189
+ });
190
+ mocks.mockIsOAuthTokenExpired.mockReturnValue(true);
191
+
192
+ const { code, stdout } = await capture(
193
+ ["login", "--list"],
194
+ { ...process.env, OFFROUTER_HOME: tmpHome },
195
+ );
196
+ expect(code).toBe(0);
197
+ expect(stdout).toContain("expired");
198
+ });
199
+ });
200
+
201
+ describe("login --status <provider>", () => {
202
+ it("shows status for unsupported provider", async () => {
203
+ const { code, stdout } = await capture(
204
+ ["login", "--status", "gemini"],
205
+ );
206
+ expect(code).toBe(0);
207
+ expect(stdout).toMatch(/not supported/i);
208
+ });
209
+ });
210
+
211
+ describe("login --json", () => {
212
+ it("returns JSON for successful login", async () => {
213
+ mocks.mockResolveClientId.mockReturnValue("test-oa-id");
214
+ mocks.mockRunOAuthLogin.mockResolvedValue({
215
+ provider: "openai",
216
+ redirectUri: "http://127.0.0.1:1455/callback",
217
+ expiresAt: 9999999999000,
218
+ scope: "offline_access",
219
+ });
220
+
221
+ const { code, stdout } = await capture(
222
+ ["login", "openai", "--json"],
223
+ {
224
+ ...process.env,
225
+ OFFROUTER_HOME: tmpHome,
226
+ },
227
+ );
228
+
229
+ expect(code).toBe(0);
230
+ const json = JSON.parse(stdout) as Record<string, unknown>;
231
+ expect(json.ok).toBe(true);
232
+ expect(json.provider).toBe("openai");
233
+ expect(json.redirectUri).toContain("127.0.0.1");
234
+ });
235
+
236
+ it("returns JSON error for unsupported provider", async () => {
237
+ const { code, stdout } = await capture(
238
+ ["login", "gemini", "--json"],
239
+ { ...process.env, OFFROUTER_HOME: tmpHome },
240
+ );
241
+
242
+ expect(code).toBe(0);
243
+ const json = JSON.parse(stdout) as Record<string, unknown>;
244
+ expect(json.error).toMatch(/not supported/i);
245
+ });
246
+ });
247
+ });