@getrouter/getrouter-cli 0.1.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.
Files changed (120) hide show
  1. package/.github/workflows/ci.yml +19 -0
  2. package/AGENTS.md +78 -0
  3. package/README.ja.md +116 -0
  4. package/README.md +116 -0
  5. package/README.zh-cn.md +116 -0
  6. package/biome.json +10 -0
  7. package/bun.lock +397 -0
  8. package/dist/bin.mjs +1422 -0
  9. package/docs/plans/2026-01-01-getrouter-cli-config-command-plan.md +231 -0
  10. package/docs/plans/2026-01-01-getrouter-cli-config-core-plan.md +307 -0
  11. package/docs/plans/2026-01-01-getrouter-cli-design.md +106 -0
  12. package/docs/plans/2026-01-01-getrouter-cli-scaffold-plan.md +327 -0
  13. package/docs/plans/2026-01-02-getrouter-cli-auth-design.md +68 -0
  14. package/docs/plans/2026-01-02-getrouter-cli-auth-device-design.md +73 -0
  15. package/docs/plans/2026-01-02-getrouter-cli-auth-device-plan.md +411 -0
  16. package/docs/plans/2026-01-02-getrouter-cli-auth-plan.md +435 -0
  17. package/docs/plans/2026-01-02-getrouter-cli-http-client-plan.md +235 -0
  18. package/docs/plans/2026-01-02-getrouter-cli-keys-create-update-output-design.md +24 -0
  19. package/docs/plans/2026-01-02-getrouter-cli-keys-create-update-output-plan.md +141 -0
  20. package/docs/plans/2026-01-02-getrouter-cli-keys-delete-output-design.md +22 -0
  21. package/docs/plans/2026-01-02-getrouter-cli-keys-delete-output-plan.md +122 -0
  22. package/docs/plans/2026-01-02-getrouter-cli-keys-get-output-design.md +23 -0
  23. package/docs/plans/2026-01-02-getrouter-cli-keys-get-output-plan.md +141 -0
  24. package/docs/plans/2026-01-02-getrouter-cli-keys-interactive-design.md +28 -0
  25. package/docs/plans/2026-01-02-getrouter-cli-keys-interactive-plan.md +247 -0
  26. package/docs/plans/2026-01-02-getrouter-cli-keys-output-design.md +31 -0
  27. package/docs/plans/2026-01-02-getrouter-cli-keys-output-plan.md +187 -0
  28. package/docs/plans/2026-01-02-getrouter-cli-keys-subscription-design.md +52 -0
  29. package/docs/plans/2026-01-02-getrouter-cli-keys-subscription-plan.md +306 -0
  30. package/docs/plans/2026-01-02-getrouter-cli-setup-env-design.md +67 -0
  31. package/docs/plans/2026-01-02-getrouter-cli-setup-env-plan.md +441 -0
  32. package/docs/plans/2026-01-02-getrouter-cli-subscription-output-design.md +34 -0
  33. package/docs/plans/2026-01-02-getrouter-cli-subscription-output-plan.md +157 -0
  34. package/docs/plans/2026-01-03-bun-migration-plan.md +103 -0
  35. package/docs/plans/2026-01-03-cli-emoji-output.md +45 -0
  36. package/docs/plans/2026-01-03-cli-english-output.md +123 -0
  37. package/docs/plans/2026-01-03-cli-simplify-design.md +62 -0
  38. package/docs/plans/2026-01-03-cli-simplify-implementation.md +468 -0
  39. package/docs/plans/2026-01-03-readme-command-descriptions.md +116 -0
  40. package/docs/plans/2026-01-03-tsdown-migration-plan.md +75 -0
  41. package/docs/plans/2026-01-04-cli-docs-cleanup-design.md +49 -0
  42. package/docs/plans/2026-01-04-cli-docs-cleanup-plan.md +126 -0
  43. package/docs/plans/2026-01-04-codex-multistep-design.md +76 -0
  44. package/docs/plans/2026-01-04-codex-multistep-plan.md +240 -0
  45. package/docs/plans/2026-01-04-env-hook-design.md +48 -0
  46. package/docs/plans/2026-01-04-env-hook-plan.md +173 -0
  47. package/docs/plans/2026-01-04-models-keys-fuzzy-design.md +75 -0
  48. package/docs/plans/2026-01-04-models-keys-fuzzy-implementation.md +704 -0
  49. package/package.json +37 -0
  50. package/src/.gitkeep +0 -0
  51. package/src/bin.ts +4 -0
  52. package/src/cli.ts +12 -0
  53. package/src/cmd/auth.ts +44 -0
  54. package/src/cmd/claude.ts +10 -0
  55. package/src/cmd/codex.ts +119 -0
  56. package/src/cmd/config-helpers.ts +16 -0
  57. package/src/cmd/config.ts +31 -0
  58. package/src/cmd/env.ts +103 -0
  59. package/src/cmd/index.ts +20 -0
  60. package/src/cmd/keys.ts +207 -0
  61. package/src/cmd/models.ts +48 -0
  62. package/src/cmd/status.ts +106 -0
  63. package/src/cmd/usages.ts +29 -0
  64. package/src/core/api/client.ts +79 -0
  65. package/src/core/auth/device.ts +105 -0
  66. package/src/core/auth/index.ts +37 -0
  67. package/src/core/config/fs.ts +13 -0
  68. package/src/core/config/index.ts +37 -0
  69. package/src/core/config/paths.ts +5 -0
  70. package/src/core/config/redact.ts +18 -0
  71. package/src/core/config/types.ts +23 -0
  72. package/src/core/http/errors.ts +32 -0
  73. package/src/core/http/request.ts +41 -0
  74. package/src/core/http/url.ts +12 -0
  75. package/src/core/interactive/clipboard.ts +61 -0
  76. package/src/core/interactive/codex.ts +75 -0
  77. package/src/core/interactive/fuzzy.ts +64 -0
  78. package/src/core/interactive/keys.ts +164 -0
  79. package/src/core/output/table.ts +34 -0
  80. package/src/core/output/usages.ts +75 -0
  81. package/src/core/paths.ts +4 -0
  82. package/src/core/setup/codex.ts +129 -0
  83. package/src/core/setup/env.ts +220 -0
  84. package/src/core/usages/aggregate.ts +69 -0
  85. package/src/generated/router/dashboard/v1/index.ts +1104 -0
  86. package/src/index.ts +1 -0
  87. package/tests/.gitkeep +0 -0
  88. package/tests/auth/device.test.ts +75 -0
  89. package/tests/auth/status.test.ts +64 -0
  90. package/tests/cli.test.ts +31 -0
  91. package/tests/cmd/auth.test.ts +90 -0
  92. package/tests/cmd/claude.test.ts +132 -0
  93. package/tests/cmd/codex.test.ts +147 -0
  94. package/tests/cmd/config-helpers.test.ts +18 -0
  95. package/tests/cmd/config.test.ts +56 -0
  96. package/tests/cmd/keys.test.ts +163 -0
  97. package/tests/cmd/models.test.ts +63 -0
  98. package/tests/cmd/status.test.ts +82 -0
  99. package/tests/cmd/usages.test.ts +42 -0
  100. package/tests/config/fs.test.ts +14 -0
  101. package/tests/config/index.test.ts +63 -0
  102. package/tests/config/paths.test.ts +10 -0
  103. package/tests/config/redact.test.ts +17 -0
  104. package/tests/config/types.test.ts +10 -0
  105. package/tests/core/api/client.test.ts +92 -0
  106. package/tests/core/interactive/clipboard.test.ts +44 -0
  107. package/tests/core/interactive/codex.test.ts +17 -0
  108. package/tests/core/interactive/fuzzy.test.ts +30 -0
  109. package/tests/core/setup/codex.test.ts +38 -0
  110. package/tests/core/setup/env.test.ts +84 -0
  111. package/tests/core/usages/aggregate.test.ts +55 -0
  112. package/tests/http/errors.test.ts +15 -0
  113. package/tests/http/request.test.ts +82 -0
  114. package/tests/http/url.test.ts +17 -0
  115. package/tests/output/table.test.ts +29 -0
  116. package/tests/output/usages.test.ts +71 -0
  117. package/tests/paths.test.ts +9 -0
  118. package/tsconfig.json +13 -0
  119. package/tsdown.config.ts +5 -0
  120. package/vitest.config.ts +7 -0
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/tests/.gitkeep ADDED
File without changes
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { generateAuthCode, pollAuthorize } from "../../src/core/auth/device";
3
+
4
+ const makeErr = (status: number) => Object.assign(new Error("err"), { status });
5
+
6
+ describe("device auth", () => {
7
+ it("polls until authorize succeeds", async () => {
8
+ const authorize = vi
9
+ .fn()
10
+ .mockRejectedValueOnce(makeErr(404))
11
+ .mockResolvedValue({
12
+ accessToken: "a",
13
+ refreshToken: "b",
14
+ expiresAt: "2026-01-03T00:00:00Z",
15
+ });
16
+ let now = 0;
17
+ const res = await pollAuthorize({
18
+ authorize,
19
+ code: "abc",
20
+ now: () => now,
21
+ sleep: async (ms) => {
22
+ now += ms;
23
+ },
24
+ initialDelayMs: 1,
25
+ maxDelayMs: 2,
26
+ timeoutMs: 100,
27
+ });
28
+ expect(res.accessToken).toBe("a");
29
+ expect(authorize).toHaveBeenCalledTimes(2);
30
+ });
31
+
32
+ it("fails on 400/403", async () => {
33
+ await expect(
34
+ pollAuthorize({
35
+ authorize: vi.fn().mockRejectedValue(makeErr(400)),
36
+ code: "abc",
37
+ sleep: async () => {},
38
+ now: () => 0,
39
+ timeoutMs: 10,
40
+ }),
41
+ ).rejects.toThrow("Auth code already used");
42
+ await expect(
43
+ pollAuthorize({
44
+ authorize: vi.fn().mockRejectedValue(makeErr(403)),
45
+ code: "abc",
46
+ sleep: async () => {},
47
+ now: () => 0,
48
+ timeoutMs: 10,
49
+ }),
50
+ ).rejects.toThrow("Auth code expired");
51
+ });
52
+
53
+ it("times out after deadline", async () => {
54
+ const authorize = vi.fn().mockRejectedValue(makeErr(404));
55
+ let now = 0;
56
+ await expect(
57
+ pollAuthorize({
58
+ authorize,
59
+ code: "abc",
60
+ now: () => now,
61
+ sleep: async (ms) => {
62
+ now += ms;
63
+ },
64
+ initialDelayMs: 5,
65
+ maxDelayMs: 5,
66
+ timeoutMs: 6,
67
+ }),
68
+ ).rejects.toThrow("Login timed out");
69
+ });
70
+
71
+ it("generates 13-char base32 auth code", () => {
72
+ const id = generateAuthCode();
73
+ expect(id).toMatch(/^[a-z2-7]{13}$/);
74
+ });
75
+ });
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { clearAuth, getAuthStatus } from "../../src/core/auth";
6
+ import { readAuth, writeAuth } from "../../src/core/config";
7
+
8
+ const makeDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
9
+
10
+ describe("auth status", () => {
11
+ it("returns logged_out when missing", () => {
12
+ const dir = makeDir();
13
+ process.env.GETROUTER_CONFIG_DIR = dir;
14
+ const status = getAuthStatus();
15
+ expect(status.status).toBe("logged_out");
16
+ });
17
+
18
+ it("returns logged_out when expired", () => {
19
+ vi.useFakeTimers();
20
+ vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
21
+ const dir = makeDir();
22
+ process.env.GETROUTER_CONFIG_DIR = dir;
23
+ writeAuth({
24
+ accessToken: "a",
25
+ refreshToken: "b",
26
+ expiresAt: "2026-01-01T00:00:00Z",
27
+ tokenType: "Bearer",
28
+ });
29
+ const status = getAuthStatus();
30
+ expect(status.status).toBe("logged_out");
31
+ vi.useRealTimers();
32
+ });
33
+
34
+ it("returns logged_in when valid", () => {
35
+ vi.useFakeTimers();
36
+ vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
37
+ const dir = makeDir();
38
+ process.env.GETROUTER_CONFIG_DIR = dir;
39
+ writeAuth({
40
+ accessToken: "tokenvalue",
41
+ refreshToken: "refreshvalue",
42
+ expiresAt: "2026-01-03T00:00:00Z",
43
+ tokenType: "Bearer",
44
+ });
45
+ const status = getAuthStatus();
46
+ expect(status.status).toBe("logged_in");
47
+ expect(status.note).toBeUndefined();
48
+ vi.useRealTimers();
49
+ });
50
+
51
+ it("clears auth state", () => {
52
+ const dir = makeDir();
53
+ process.env.GETROUTER_CONFIG_DIR = dir;
54
+ writeAuth({
55
+ accessToken: "a",
56
+ refreshToken: "b",
57
+ expiresAt: "c",
58
+ tokenType: "Bearer",
59
+ });
60
+ clearAuth();
61
+ const auth = readAuth();
62
+ expect(auth.accessToken).toBe("");
63
+ });
64
+ });
@@ -0,0 +1,31 @@
1
+ import { readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { createProgram } from "../src/cli";
5
+
6
+ describe("getrouter cli", () => {
7
+ it("exposes name and help", () => {
8
+ const program = createProgram();
9
+ expect(program.name()).toBe("getrouter");
10
+ expect(program.helpInformation()).toContain("getrouter");
11
+ });
12
+
13
+ it("only ships registered command entrypoints", () => {
14
+ const cmdDir = path.join(process.cwd(), "src", "cmd");
15
+ const files = readdirSync(cmdDir).filter((file) => file.endsWith(".ts"));
16
+ const expected = [
17
+ "auth.ts",
18
+ "claude.ts",
19
+ "codex.ts",
20
+ "config-helpers.ts",
21
+ "config.ts",
22
+ "env.ts",
23
+ "index.ts",
24
+ "keys.ts",
25
+ "models.ts",
26
+ "status.ts",
27
+ "usages.ts",
28
+ ];
29
+ expect(files.sort()).toEqual(expected.sort());
30
+ });
31
+ });
@@ -0,0 +1,90 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { createProgram } from "../../src/cli";
6
+ import { createApiClients } from "../../src/core/api/client";
7
+ import {
8
+ buildLoginUrl,
9
+ generateAuthCode,
10
+ openLoginUrl,
11
+ pollAuthorize,
12
+ } from "../../src/core/auth/device";
13
+ import { writeAuth } from "../../src/core/config";
14
+ import type {
15
+ AuthService,
16
+ ConsumerService,
17
+ SubscriptionService,
18
+ } from "../../src/generated/router/dashboard/v1";
19
+
20
+ vi.mock("../../src/core/api/client", () => ({
21
+ createApiClients: vi.fn(),
22
+ }));
23
+
24
+ vi.mock("../../src/core/auth/device", async () => {
25
+ const actual = await vi.importActual<
26
+ typeof import("../../src/core/auth/device")
27
+ >("../../src/core/auth/device");
28
+ return {
29
+ ...actual,
30
+ generateAuthCode: vi.fn(() => "abcde234567fg"),
31
+ openLoginUrl: vi.fn(async () => {}),
32
+ pollAuthorize: vi.fn(),
33
+ };
34
+ });
35
+
36
+ const makeDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
37
+
38
+ describe("auth commands", () => {
39
+ it("login polls authorize and writes auth.json", async () => {
40
+ const dir = makeDir();
41
+ process.env.GETROUTER_CONFIG_DIR = dir;
42
+ const authService = {
43
+ Authorize: vi.fn(),
44
+ CreateAuth: vi.fn(),
45
+ RefreshToken: vi.fn(),
46
+ } as AuthService;
47
+ (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
48
+ authService,
49
+ consumerService: {} as unknown as ConsumerService,
50
+ subscriptionService: {} as SubscriptionService,
51
+ });
52
+ (pollAuthorize as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
53
+ accessToken: "access",
54
+ refreshToken: "refresh",
55
+ expiresAt: "2026-01-03T00:00:00Z",
56
+ });
57
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
58
+ const program = createProgram();
59
+ await program.parseAsync(["node", "getrouter", "login"]);
60
+ const saved = JSON.parse(
61
+ fs.readFileSync(path.join(dir, "auth.json"), "utf-8"),
62
+ );
63
+ expect(saved.accessToken).toBe("access");
64
+ expect(saved.refreshToken).toBe("refresh");
65
+ expect(saved.tokenType).toBe("Bearer");
66
+ expect(openLoginUrl).toHaveBeenCalledWith(
67
+ buildLoginUrl(
68
+ (generateAuthCode as unknown as ReturnType<typeof vi.fn>).mock
69
+ .results[0].value,
70
+ ),
71
+ );
72
+ log.mockRestore();
73
+ });
74
+
75
+ it("logout clears local auth", async () => {
76
+ const dir = makeDir();
77
+ process.env.GETROUTER_CONFIG_DIR = dir;
78
+ writeAuth({
79
+ accessToken: "a",
80
+ refreshToken: "b",
81
+ expiresAt: "c",
82
+ tokenType: "Bearer",
83
+ });
84
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
85
+ const program = createProgram();
86
+ await program.parseAsync(["node", "getrouter", "logout"]);
87
+ expect(log.mock.calls[0][0]).toContain("Cleared local auth data");
88
+ log.mockRestore();
89
+ });
90
+ });
@@ -0,0 +1,132 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import prompts from "prompts";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { createProgram } from "../../src/cli";
7
+ import { createApiClients } from "../../src/core/api/client";
8
+ import {
9
+ getEnvFilePath,
10
+ getHookFilePath,
11
+ resolveShellRcPath,
12
+ } from "../../src/core/setup/env";
13
+ import type {
14
+ AuthService,
15
+ ConsumerService,
16
+ SubscriptionService,
17
+ } from "../../src/generated/router/dashboard/v1";
18
+
19
+ vi.mock("../../src/core/api/client", () => ({
20
+ createApiClients: vi.fn(),
21
+ }));
22
+
23
+ const makeDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
24
+ const mockConsumer = { id: "c1", apiKey: "key-123" };
25
+
26
+ const originalIsTTY = process.stdin.isTTY;
27
+ const setStdinTTY = (value: boolean) => {
28
+ Object.defineProperty(process.stdin, "isTTY", {
29
+ value,
30
+ configurable: true,
31
+ });
32
+ };
33
+
34
+ const ENV_KEYS = [
35
+ "GETROUTER_CONFIG_DIR",
36
+ "HOME",
37
+ "SHELL",
38
+ "OPENAI_BASE_URL",
39
+ "OPENAI_API_KEY",
40
+ "ANTHROPIC_BASE_URL",
41
+ "ANTHROPIC_API_KEY",
42
+ ];
43
+
44
+ const originalEnv = Object.fromEntries(
45
+ ENV_KEYS.map((key) => [key, process.env[key]]),
46
+ );
47
+
48
+ afterEach(() => {
49
+ setStdinTTY(originalIsTTY);
50
+ prompts.inject([]);
51
+ for (const key of ENV_KEYS) {
52
+ if (originalEnv[key] === undefined) {
53
+ delete process.env[key];
54
+ } else {
55
+ process.env[key] = originalEnv[key];
56
+ }
57
+ }
58
+ });
59
+
60
+ describe("claude command", () => {
61
+ it("writes Anthropic-only env file", async () => {
62
+ setStdinTTY(true);
63
+ prompts.inject([mockConsumer]);
64
+ const dir = makeDir();
65
+ process.env.GETROUTER_CONFIG_DIR = dir;
66
+ process.env.HOME = dir;
67
+ process.env.SHELL = "/bin/bash";
68
+ (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
69
+ consumerService: {
70
+ ListConsumers: vi.fn().mockResolvedValue({
71
+ consumers: [
72
+ {
73
+ id: "c1",
74
+ name: "dev",
75
+ enabled: true,
76
+ createdAt: "2026-01-01T00:00:00Z",
77
+ },
78
+ ],
79
+ }),
80
+ GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
81
+ } as unknown as ConsumerService,
82
+ subscriptionService: {} as SubscriptionService,
83
+ authService: {} as AuthService,
84
+ });
85
+ const program = createProgram();
86
+ await program.parseAsync(["node", "getrouter", "claude"]);
87
+ const content = fs.readFileSync(getEnvFilePath("sh", dir), "utf8");
88
+ expect(content).toContain("ANTHROPIC_BASE_URL");
89
+ expect(content).toContain("ANTHROPIC_API_KEY");
90
+ expect(content).not.toContain("OPENAI_BASE_URL");
91
+ expect(content).not.toContain("OPENAI_API_KEY");
92
+ });
93
+
94
+ it("installs into rc and updates process env", async () => {
95
+ setStdinTTY(true);
96
+ prompts.inject([mockConsumer]);
97
+ const dir = makeDir();
98
+ process.env.GETROUTER_CONFIG_DIR = dir;
99
+ process.env.HOME = dir;
100
+ process.env.SHELL = "/bin/bash";
101
+ (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
102
+ consumerService: {
103
+ ListConsumers: vi.fn().mockResolvedValue({
104
+ consumers: [
105
+ {
106
+ id: "c1",
107
+ name: "dev",
108
+ enabled: true,
109
+ createdAt: "2026-01-01T00:00:00Z",
110
+ },
111
+ ],
112
+ }),
113
+ GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
114
+ } as unknown as ConsumerService,
115
+ subscriptionService: {} as SubscriptionService,
116
+ authService: {} as AuthService,
117
+ });
118
+ const program = createProgram();
119
+ await program.parseAsync(["node", "getrouter", "claude", "--install"]);
120
+ const envPath = getEnvFilePath("sh", dir);
121
+ const hookPath = getHookFilePath("bash", dir);
122
+ const rcPath = resolveShellRcPath("bash", dir);
123
+ const rcContent = fs.readFileSync(rcPath ?? "", "utf8");
124
+ expect(rcContent).toContain(`source ${envPath}`);
125
+ expect(rcContent).toContain(`source ${hookPath}`);
126
+ expect(fs.existsSync(hookPath)).toBe(true);
127
+ expect(process.env.ANTHROPIC_BASE_URL).toBe(
128
+ "https://api.getrouter.dev/claude",
129
+ );
130
+ expect(process.env.ANTHROPIC_API_KEY).toBe("key-123");
131
+ });
132
+ });
@@ -0,0 +1,147 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import prompts from "prompts";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { createProgram } from "../../src/cli";
7
+ import { createApiClients } from "../../src/core/api/client";
8
+ import type { ConsumerService } from "../../src/generated/router/dashboard/v1";
9
+
10
+ vi.mock("../../src/core/api/client", () => ({
11
+ createApiClients: vi.fn(),
12
+ }));
13
+
14
+ const makeDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
15
+ const codexConfigPath = (dir: string) =>
16
+ path.join(dir, ".codex", "config.toml");
17
+ const codexAuthPath = (dir: string) => path.join(dir, ".codex", "auth.json");
18
+
19
+ const mockConsumer = { id: "c1", apiKey: "key-123" };
20
+
21
+ const originalIsTTY = process.stdin.isTTY;
22
+ const setStdinTTY = (value: boolean) => {
23
+ Object.defineProperty(process.stdin, "isTTY", {
24
+ value,
25
+ configurable: true,
26
+ });
27
+ };
28
+
29
+ const ENV_KEYS = ["GETROUTER_CONFIG_DIR", "HOME", "SHELL"];
30
+ const originalEnv = Object.fromEntries(
31
+ ENV_KEYS.map((key) => [key, process.env[key]]),
32
+ );
33
+
34
+ afterEach(() => {
35
+ setStdinTTY(originalIsTTY);
36
+ prompts.inject([]);
37
+ for (const key of ENV_KEYS) {
38
+ if (originalEnv[key] === undefined) {
39
+ delete process.env[key];
40
+ } else {
41
+ process.env[key] = originalEnv[key];
42
+ }
43
+ }
44
+ });
45
+
46
+ describe("codex command", () => {
47
+ it("writes codex config and auth after interactive flow", async () => {
48
+ setStdinTTY(true);
49
+ const dir = makeDir();
50
+ process.env.HOME = dir;
51
+ prompts.inject(["gpt-5.2-codex", "extra_high", mockConsumer, true]);
52
+ (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
53
+ consumerService: {
54
+ ListConsumers: vi.fn().mockResolvedValue({
55
+ consumers: [
56
+ {
57
+ id: "c1",
58
+ name: "dev",
59
+ enabled: true,
60
+ createdAt: "2026-01-01T00:00:00Z",
61
+ },
62
+ ],
63
+ }),
64
+ GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
65
+ } as unknown as ConsumerService,
66
+ });
67
+
68
+ const program = createProgram();
69
+ await program.parseAsync(["node", "getrouter", "codex"]);
70
+
71
+ const config = fs.readFileSync(codexConfigPath(dir), "utf8");
72
+ expect(config).toContain('model = "gpt-5.2-codex"');
73
+ expect(config).toContain('model_reasoning_effort = "xhigh"');
74
+ expect(config).toContain('model_provider = "getrouter"');
75
+ expect(config).toContain("[model_providers.getrouter]");
76
+ expect(config).toContain('name = "getrouter"');
77
+ expect(config).toContain('base_url = "https://api.getrouter.dev/codex"');
78
+ expect(config).toContain('wire_api = "responses"');
79
+ expect(config).toContain("requires_openai_auth = true");
80
+
81
+ const auth = JSON.parse(fs.readFileSync(codexAuthPath(dir), "utf8"));
82
+ expect(auth.OPENAI_API_KEY).toBe("key-123");
83
+ });
84
+
85
+ it("merges existing codex config and auth", async () => {
86
+ setStdinTTY(true);
87
+ const dir = makeDir();
88
+ process.env.HOME = dir;
89
+ const codexDir = path.join(dir, ".codex");
90
+ fs.mkdirSync(codexDir, { recursive: true });
91
+ fs.writeFileSync(
92
+ codexConfigPath(dir),
93
+ [
94
+ 'theme = "dark"',
95
+ 'model = "old-model"',
96
+ 'model_provider = "other"',
97
+ "",
98
+ "[model_providers.other]",
99
+ 'name = "other"',
100
+ 'base_url = "https://example.com"',
101
+ "",
102
+ "[model_providers.getrouter]",
103
+ 'name = "old"',
104
+ 'base_url = "https://old.example.com"',
105
+ 'extra = "keep"',
106
+ "",
107
+ ].join("\n"),
108
+ );
109
+ fs.writeFileSync(
110
+ codexAuthPath(dir),
111
+ JSON.stringify({ OTHER: "keep", OPENAI_API_KEY: "old" }, null, 2),
112
+ );
113
+ prompts.inject(["gpt-5.2", "low", mockConsumer, true]);
114
+ (createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
115
+ consumerService: {
116
+ ListConsumers: vi.fn().mockResolvedValue({
117
+ consumers: [
118
+ {
119
+ id: "c1",
120
+ name: "dev",
121
+ enabled: true,
122
+ createdAt: "2026-01-01T00:00:00Z",
123
+ },
124
+ ],
125
+ }),
126
+ GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
127
+ } as unknown as ConsumerService,
128
+ });
129
+
130
+ const program = createProgram();
131
+ await program.parseAsync(["node", "getrouter", "codex"]);
132
+
133
+ const config = fs.readFileSync(codexConfigPath(dir), "utf8");
134
+ expect(config).toContain('theme = "dark"');
135
+ expect(config).toContain("[model_providers.other]");
136
+ expect(config).toContain('base_url = "https://example.com"');
137
+ expect(config).toContain('extra = "keep"');
138
+ expect(config).toContain('model = "gpt-5.2"');
139
+ expect(config).toContain('model_reasoning_effort = "low"');
140
+ expect(config).toContain('model_provider = "getrouter"');
141
+ expect(config).toContain('base_url = "https://api.getrouter.dev/codex"');
142
+
143
+ const auth = JSON.parse(fs.readFileSync(codexAuthPath(dir), "utf8"));
144
+ expect(auth.OPENAI_API_KEY).toBe("key-123");
145
+ expect(auth.OTHER).toBe("keep");
146
+ });
147
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ normalizeApiBase,
4
+ parseConfigValue,
5
+ } from "../../src/cmd/config-helpers";
6
+
7
+ describe("config helpers", () => {
8
+ it("normalizes apiBase", () => {
9
+ expect(normalizeApiBase("https://getrouter.dev/")).toBe(
10
+ "https://getrouter.dev",
11
+ );
12
+ });
13
+
14
+ it("parses json values", () => {
15
+ expect(parseConfigValue("json", "true")).toBe(true);
16
+ expect(parseConfigValue("json", "0")).toBe(false);
17
+ });
18
+ });
@@ -0,0 +1,56 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { createProgram } from "../../src/cli";
6
+ import { parseConfigValue } from "../../src/cmd/config-helpers";
7
+
8
+ const makeDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
9
+ const originalConfigDir = process.env.GETROUTER_CONFIG_DIR;
10
+
11
+ afterEach(() => {
12
+ if (originalConfigDir == null) {
13
+ delete process.env.GETROUTER_CONFIG_DIR;
14
+ } else {
15
+ process.env.GETROUTER_CONFIG_DIR = originalConfigDir;
16
+ }
17
+ });
18
+
19
+ describe("config command", () => {
20
+ it("prints full config when no args", async () => {
21
+ process.env.GETROUTER_CONFIG_DIR = makeDir();
22
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
23
+ const program = createProgram();
24
+ await program.parseAsync(["node", "getrouter", "config"]);
25
+ const output = log.mock.calls.map((c) => c[0]).join("\n");
26
+ expect(output).toContain("apiBase=");
27
+ expect(output).toContain("json=");
28
+ log.mockRestore();
29
+ });
30
+
31
+ it("sets config key and prints value", async () => {
32
+ process.env.GETROUTER_CONFIG_DIR = makeDir();
33
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
34
+ const program = createProgram();
35
+ await program.parseAsync([
36
+ "node",
37
+ "getrouter",
38
+ "config",
39
+ "apiBase",
40
+ "https://getrouter.dev",
41
+ ]);
42
+ const output = log.mock.calls.map((c) => c[0]).join("\n");
43
+ expect(output).toContain("apiBase=https://getrouter.dev");
44
+ log.mockRestore();
45
+ });
46
+ });
47
+
48
+ describe("config validation", () => {
49
+ it("rejects invalid json value", () => {
50
+ expect(() => parseConfigValue("json", "nope")).toThrow();
51
+ });
52
+
53
+ it("rejects invalid apiBase", () => {
54
+ expect(() => parseConfigValue("apiBase", "ftp://bad")).toThrow();
55
+ });
56
+ });