@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
@@ -0,0 +1,64 @@
1
+ import prompts from "prompts";
2
+
3
+ export type FuzzyChoice<T> = {
4
+ title: string;
5
+ value: T;
6
+ keywords?: string[];
7
+ description?: string;
8
+ };
9
+
10
+ const normalize = (value: string) => value.toLowerCase();
11
+
12
+ const fuzzyScore = (query: string, target: string): number | null => {
13
+ if (!query) return 0;
14
+ let score = 0;
15
+ let lastIndex = -1;
16
+ for (const ch of query) {
17
+ const index = target.indexOf(ch, lastIndex + 1);
18
+ if (index === -1) return null;
19
+ score += index;
20
+ lastIndex = index;
21
+ }
22
+ return score;
23
+ };
24
+
25
+ const toSearchText = <T>(choice: FuzzyChoice<T>) =>
26
+ normalize([choice.title, ...(choice.keywords ?? [])].join(" ").trim());
27
+
28
+ export const rankFuzzyChoices = <T>(
29
+ choices: FuzzyChoice<T>[],
30
+ input: string,
31
+ limit = 50,
32
+ ) => {
33
+ const query = normalize(input.trim());
34
+ if (!query) return choices.slice(0, limit);
35
+ const ranked = choices
36
+ .map((choice) => {
37
+ const score = fuzzyScore(query, toSearchText(choice));
38
+ return score == null ? null : { choice, score };
39
+ })
40
+ .filter(Boolean) as { choice: FuzzyChoice<T>; score: number }[];
41
+ ranked.sort(
42
+ (a, b) => a.score - b.score || a.choice.title.localeCompare(b.choice.title),
43
+ );
44
+ return ranked.slice(0, limit).map((entry) => entry.choice);
45
+ };
46
+
47
+ export const fuzzySelect = async <T>({
48
+ message,
49
+ choices,
50
+ }: {
51
+ message: string;
52
+ choices: FuzzyChoice<T>[];
53
+ }): Promise<T | null> => {
54
+ const response = await prompts({
55
+ type: "autocomplete",
56
+ name: "value",
57
+ message,
58
+ choices,
59
+ suggest: async (input, items) =>
60
+ rankFuzzyChoices(items as FuzzyChoice<T>[], String(input)),
61
+ });
62
+ if (response.value == null || response.value === "") return null;
63
+ return response.value as T;
64
+ };
@@ -0,0 +1,164 @@
1
+ import prompts from "prompts";
2
+ import type {
3
+ ConsumerService as DashboardConsumerService,
4
+ routercommonv1_Consumer,
5
+ } from "../../generated/router/dashboard/v1";
6
+ import { fuzzySelect } from "./fuzzy";
7
+
8
+ type Consumer = routercommonv1_Consumer;
9
+ type ConsumerService = Pick<DashboardConsumerService, "ListConsumers">;
10
+
11
+ export type KeyMenuAction =
12
+ | "list"
13
+ | "view"
14
+ | "create"
15
+ | "update"
16
+ | "delete"
17
+ | "exit";
18
+
19
+ const sortByCreatedAtDesc = (consumers: Consumer[]) =>
20
+ consumers.slice().sort((a, b) => {
21
+ const aTime = Date.parse(a.createdAt ?? "") || 0;
22
+ const bTime = Date.parse(b.createdAt ?? "") || 0;
23
+ return bTime - aTime;
24
+ });
25
+
26
+ const normalizeName = (consumer: Consumer) => {
27
+ const name = consumer.name?.trim();
28
+ return name && name.length > 0 ? name : "(unnamed)";
29
+ };
30
+
31
+ const buildNameCounts = (consumers: Consumer[]) => {
32
+ const counts = new Map<string, number>();
33
+ for (const consumer of consumers) {
34
+ const name = normalizeName(consumer);
35
+ counts.set(name, (counts.get(name) ?? 0) + 1);
36
+ }
37
+ return counts;
38
+ };
39
+
40
+ const formatChoice = (consumer: Consumer, nameCounts: Map<string, number>) => {
41
+ const name = normalizeName(consumer);
42
+ const createdAt = consumer.createdAt ?? "-";
43
+ const needsDetail = (nameCounts.get(name) ?? 0) > 1 || name === "(unnamed)";
44
+ return needsDetail ? `${name} (${createdAt})` : name;
45
+ };
46
+
47
+ export const selectKeyAction = async (): Promise<KeyMenuAction> => {
48
+ const actions: KeyMenuAction[] = [
49
+ "list",
50
+ "view",
51
+ "create",
52
+ "update",
53
+ "delete",
54
+ "exit",
55
+ ];
56
+ const response = await prompts({
57
+ type: "select",
58
+ name: "action",
59
+ message: "🔑 Select an action",
60
+ choices: [
61
+ { title: "List keys", value: "list" },
62
+ { title: "View key", value: "view" },
63
+ { title: "Create key", value: "create" },
64
+ { title: "Update key", value: "update" },
65
+ { title: "Delete key", value: "delete" },
66
+ { title: "Exit", value: "exit" },
67
+ ],
68
+ });
69
+ if (typeof response.action === "number") {
70
+ return actions[response.action] ?? "exit";
71
+ }
72
+ if (typeof response.action === "string") {
73
+ return response.action as KeyMenuAction;
74
+ }
75
+ return "exit";
76
+ };
77
+
78
+ export const promptKeyName = async (
79
+ initial?: string,
80
+ ): Promise<string | undefined> => {
81
+ const response = await prompts({
82
+ type: "text",
83
+ name: "name",
84
+ message: "Key name",
85
+ initial: initial ?? "",
86
+ });
87
+ const value = typeof response.name === "string" ? response.name.trim() : "";
88
+ return value.length > 0 ? value : undefined;
89
+ };
90
+
91
+ export const promptKeyEnabled = async (initial: boolean): Promise<boolean> => {
92
+ const response = await prompts({
93
+ type: "confirm",
94
+ name: "enabled",
95
+ message: "Enable this key?",
96
+ initial,
97
+ });
98
+ return typeof response.enabled === "boolean" ? response.enabled : initial;
99
+ };
100
+
101
+ export const selectConsumer = async (
102
+ consumerService: ConsumerService,
103
+ ): Promise<routercommonv1_Consumer | null> => {
104
+ const res = await consumerService.ListConsumers({
105
+ pageSize: undefined,
106
+ pageToken: undefined,
107
+ });
108
+ const consumers = res?.consumers ?? [];
109
+ if (consumers.length === 0) {
110
+ throw new Error("No available API keys");
111
+ }
112
+ const sorted = sortByCreatedAtDesc(consumers);
113
+ const nameCounts = buildNameCounts(sorted);
114
+ const selected = await fuzzySelect({
115
+ message: "🔎 Search keys",
116
+ choices: sorted.map((consumer) => ({
117
+ title: formatChoice(consumer, nameCounts),
118
+ value: consumer,
119
+ keywords: [normalizeName(consumer), consumer.createdAt ?? ""].filter(
120
+ Boolean,
121
+ ),
122
+ })),
123
+ });
124
+ return selected ?? null;
125
+ };
126
+
127
+ export const selectConsumerList = async (
128
+ consumerService: ConsumerService,
129
+ message: string,
130
+ ): Promise<routercommonv1_Consumer | null> => {
131
+ const res = await consumerService.ListConsumers({
132
+ pageSize: undefined,
133
+ pageToken: undefined,
134
+ });
135
+ const consumers = res?.consumers ?? [];
136
+ if (consumers.length === 0) {
137
+ throw new Error("No available API keys");
138
+ }
139
+ const sorted = sortByCreatedAtDesc(consumers);
140
+ const nameCounts = buildNameCounts(sorted);
141
+ const response = await prompts({
142
+ type: "select",
143
+ name: "value",
144
+ message,
145
+ choices: sorted.map((consumer) => ({
146
+ title: formatChoice(consumer, nameCounts),
147
+ value: consumer,
148
+ })),
149
+ });
150
+ if (response.value == null || response.value === "") return null;
151
+ return response.value as routercommonv1_Consumer;
152
+ };
153
+
154
+ export const confirmDelete = async (consumer: Consumer) => {
155
+ const name = consumer.name ?? "-";
156
+ const id = consumer.id ?? "-";
157
+ const response = await prompts({
158
+ type: "confirm",
159
+ name: "confirm",
160
+ message: `⚠️ Confirm delete ${name} (${id})?`,
161
+ initial: false,
162
+ });
163
+ return Boolean(response.confirm);
164
+ };
@@ -0,0 +1,34 @@
1
+ type TableOptions = { maxColWidth?: number };
2
+
3
+ const truncate = (value: string, max: number) => {
4
+ if (value.length <= max) return value;
5
+ if (max <= 3) return value.slice(0, max);
6
+ return `${value.slice(0, max - 3)}...`;
7
+ };
8
+
9
+ export const renderTable = (
10
+ headers: string[],
11
+ rows: string[][],
12
+ options: TableOptions = {},
13
+ ) => {
14
+ const maxColWidth = options.maxColWidth ?? 32;
15
+ const normalized = rows.map((row) =>
16
+ row.map((cell) => (cell && cell.length > 0 ? cell : "-")),
17
+ );
18
+ const widths = headers.map((header, index) => {
19
+ const colValues = normalized.map((row) => row[index] ?? "-");
20
+ const maxLen = Math.max(header.length, ...colValues.map((v) => v.length));
21
+ return Math.min(maxLen, maxColWidth);
22
+ });
23
+ const renderRow = (cells: string[]) =>
24
+ cells
25
+ .map((cell, index) => {
26
+ const raw = cell ?? "-";
27
+ const clipped = truncate(raw, widths[index]);
28
+ return clipped.padEnd(widths[index], " ");
29
+ })
30
+ .join(" ");
31
+ const headerRow = renderRow(headers);
32
+ const body = normalized.map((row) => renderRow(row)).join("\n");
33
+ return `${headerRow}\n${body}`;
34
+ };
@@ -0,0 +1,75 @@
1
+ import type { AggregatedUsage } from "../usages/aggregate";
2
+
3
+ const INPUT_BLOCK = "█";
4
+ const OUTPUT_BLOCK = "▒";
5
+ const DEFAULT_WIDTH = 24;
6
+
7
+ const formatTokens = (value: number) => {
8
+ const abs = Math.abs(value);
9
+ if (abs < 1000) return Math.round(value).toString();
10
+ const units = [
11
+ { threshold: 1_000_000_000, suffix: "B" },
12
+ { threshold: 1_000_000, suffix: "M" },
13
+ { threshold: 1_000, suffix: "K" },
14
+ ];
15
+ for (const unit of units) {
16
+ if (abs >= unit.threshold) {
17
+ const scaled = value / unit.threshold;
18
+ const decimals = Math.abs(scaled) < 10 ? 1 : 0;
19
+ let output = scaled.toFixed(decimals);
20
+ if (output.endsWith(".0")) {
21
+ output = output.slice(0, -2);
22
+ }
23
+ return `${output}${unit.suffix}`;
24
+ }
25
+ }
26
+ return Math.round(value).toString();
27
+ };
28
+
29
+ export const renderUsageChart = (
30
+ rows: AggregatedUsage[],
31
+ width = DEFAULT_WIDTH,
32
+ ) => {
33
+ const header = "📊 Usage (last 7 days) · Tokens";
34
+ if (rows.length === 0) {
35
+ return `${header}\n\nNo usage data available.`;
36
+ }
37
+ const normalized = rows.map((row) => {
38
+ const input = Number(row.inputTokens);
39
+ const output = Number(row.outputTokens);
40
+ const safeInput = Number.isFinite(input) ? input : 0;
41
+ const safeOutput = Number.isFinite(output) ? output : 0;
42
+ return {
43
+ day: row.day,
44
+ input: safeInput,
45
+ output: safeOutput,
46
+ total: safeInput + safeOutput,
47
+ };
48
+ });
49
+ const totals = normalized.map((row) => row.total);
50
+ const maxTotal = Math.max(0, ...totals);
51
+ const lines = normalized.map((row) => {
52
+ const total = row.total;
53
+ if (maxTotal === 0 || total === 0) {
54
+ return `${row.day} ${"".padEnd(width, " ")} I:0 O:0`;
55
+ }
56
+ const scaled = Math.max(1, Math.round((total / maxTotal) * width));
57
+ let inputBars = Math.round((row.input / total) * scaled);
58
+ let outputBars = Math.max(0, scaled - inputBars);
59
+ if (row.input > 0 && row.output > 0) {
60
+ if (inputBars === 0) {
61
+ inputBars = 1;
62
+ outputBars = Math.max(0, scaled - 1);
63
+ } else if (outputBars === 0) {
64
+ outputBars = 1;
65
+ inputBars = Math.max(0, scaled - 1);
66
+ }
67
+ }
68
+ const bar = `${INPUT_BLOCK.repeat(inputBars)}${OUTPUT_BLOCK.repeat(outputBars)}`;
69
+ const inputLabel = formatTokens(row.input);
70
+ const outputLabel = formatTokens(row.output);
71
+ return `${row.day} ${bar.padEnd(width, " ")} I:${inputLabel} O:${outputLabel}`;
72
+ });
73
+ const legend = `Legend: ${INPUT_BLOCK} input ${OUTPUT_BLOCK} output`;
74
+ return [header, "", ...lines, "", legend].join("\n");
75
+ };
@@ -0,0 +1,4 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export const getConfigDir = () => path.join(os.homedir(), ".getrouter");
@@ -0,0 +1,129 @@
1
+ type CodexConfigInput = {
2
+ model: string;
3
+ reasoning: string;
4
+ };
5
+
6
+ const CODEX_PROVIDER = "getrouter";
7
+ const CODEX_BASE_URL = "https://api.getrouter.dev/codex";
8
+
9
+ const ROOT_KEYS = [
10
+ "model",
11
+ "model_reasoning_effort",
12
+ "model_provider",
13
+ ] as const;
14
+ const PROVIDER_SECTION = `model_providers.${CODEX_PROVIDER}`;
15
+ const PROVIDER_KEYS = [
16
+ "name",
17
+ "base_url",
18
+ "wire_api",
19
+ "requires_openai_auth",
20
+ ] as const;
21
+
22
+ const rootValues = (input: CodexConfigInput) => ({
23
+ model: `"${input.model}"`,
24
+ model_reasoning_effort: `"${input.reasoning}"`,
25
+ model_provider: `"${CODEX_PROVIDER}"`,
26
+ });
27
+
28
+ const providerValues = () => ({
29
+ name: `"${CODEX_PROVIDER}"`,
30
+ base_url: `"${CODEX_BASE_URL}"`,
31
+ wire_api: `"responses"`,
32
+ requires_openai_auth: "true",
33
+ });
34
+
35
+ const matchHeader = (line: string) => line.match(/^\s*\[([^\]]+)\]\s*$/);
36
+ const matchKey = (line: string) => line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
37
+
38
+ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
39
+ const lines = content.length ? content.split(/\r?\n/) : [];
40
+ const updated = [...lines];
41
+ const rootValueMap = rootValues(input);
42
+ const providerValueMap = providerValues();
43
+
44
+ let currentSection: string | null = null;
45
+ let firstHeaderIndex: number | null = null;
46
+ const rootFound = new Set<string>();
47
+
48
+ for (let i = 0; i < updated.length; i += 1) {
49
+ const headerMatch = matchHeader(updated[i] ?? "");
50
+ if (headerMatch) {
51
+ currentSection = headerMatch[1]?.trim() ?? null;
52
+ if (firstHeaderIndex === null) {
53
+ firstHeaderIndex = i;
54
+ }
55
+ continue;
56
+ }
57
+ if (currentSection !== null) {
58
+ continue;
59
+ }
60
+ const keyMatch = matchKey(updated[i] ?? "");
61
+ if (!keyMatch) continue;
62
+ const key = keyMatch[1] as keyof typeof rootValueMap;
63
+ if (ROOT_KEYS.includes(key)) {
64
+ updated[i] = `${key} = ${rootValueMap[key]}`;
65
+ rootFound.add(key);
66
+ }
67
+ }
68
+
69
+ const insertIndex = firstHeaderIndex ?? updated.length;
70
+ const missingRoot = ROOT_KEYS.filter((key) => !rootFound.has(key)).map(
71
+ (key) => `${key} = ${rootValueMap[key]}`,
72
+ );
73
+ if (missingRoot.length > 0) {
74
+ const needsBlank =
75
+ insertIndex < updated.length && updated[insertIndex]?.trim() !== "";
76
+ updated.splice(insertIndex, 0, ...missingRoot, ...(needsBlank ? [""] : []));
77
+ }
78
+
79
+ const providerHeader = `[${PROVIDER_SECTION}]`;
80
+ const providerHeaderIndex = updated.findIndex(
81
+ (line) => line.trim() === providerHeader,
82
+ );
83
+ if (providerHeaderIndex === -1) {
84
+ if (updated.length > 0 && updated[updated.length - 1]?.trim() !== "") {
85
+ updated.push("");
86
+ }
87
+ updated.push(providerHeader);
88
+ for (const key of PROVIDER_KEYS) {
89
+ updated.push(`${key} = ${providerValueMap[key]}`);
90
+ }
91
+ return updated.join("\n");
92
+ }
93
+
94
+ let providerEnd = updated.length;
95
+ for (let i = providerHeaderIndex + 1; i < updated.length; i += 1) {
96
+ if (matchHeader(updated[i] ?? "")) {
97
+ providerEnd = i;
98
+ break;
99
+ }
100
+ }
101
+
102
+ const providerFound = new Set<string>();
103
+ for (let i = providerHeaderIndex + 1; i < providerEnd; i += 1) {
104
+ const keyMatch = matchKey(updated[i] ?? "");
105
+ if (!keyMatch) continue;
106
+ const key = keyMatch[1] as keyof typeof providerValueMap;
107
+ if (PROVIDER_KEYS.includes(key)) {
108
+ updated[i] = `${key} = ${providerValueMap[key]}`;
109
+ providerFound.add(key);
110
+ }
111
+ }
112
+
113
+ const missingProvider = PROVIDER_KEYS.filter(
114
+ (key) => !providerFound.has(key),
115
+ ).map((key) => `${key} = ${providerValueMap[key]}`);
116
+ if (missingProvider.length > 0) {
117
+ updated.splice(providerEnd, 0, ...missingProvider);
118
+ }
119
+
120
+ return updated.join("\n");
121
+ };
122
+
123
+ export const mergeAuthJson = (
124
+ data: Record<string, unknown>,
125
+ apiKey: string,
126
+ ): Record<string, unknown> => ({
127
+ ...data,
128
+ OPENAI_API_KEY: apiKey,
129
+ });
@@ -0,0 +1,220 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export type EnvVars = {
7
+ openaiBaseUrl?: string;
8
+ openaiApiKey?: string;
9
+ anthropicBaseUrl?: string;
10
+ anthropicApiKey?: string;
11
+ };
12
+
13
+ export type EnvShell = "sh" | "ps1";
14
+
15
+ export type RcShell = "zsh" | "bash" | "fish" | "pwsh";
16
+
17
+ const renderLine = (shell: EnvShell, key: string, value: string) => {
18
+ if (shell === "ps1") {
19
+ return `$env:${key}="${value}"`;
20
+ }
21
+ return `export ${key}=${value}`;
22
+ };
23
+
24
+ export const renderEnv = (shell: EnvShell, vars: EnvVars) => {
25
+ const lines: string[] = [];
26
+ if (vars.openaiBaseUrl) {
27
+ lines.push(renderLine(shell, "OPENAI_BASE_URL", vars.openaiBaseUrl));
28
+ }
29
+ if (vars.openaiApiKey) {
30
+ lines.push(renderLine(shell, "OPENAI_API_KEY", vars.openaiApiKey));
31
+ }
32
+ if (vars.anthropicBaseUrl) {
33
+ lines.push(renderLine(shell, "ANTHROPIC_BASE_URL", vars.anthropicBaseUrl));
34
+ }
35
+ if (vars.anthropicApiKey) {
36
+ lines.push(renderLine(shell, "ANTHROPIC_API_KEY", vars.anthropicApiKey));
37
+ }
38
+ lines.push("");
39
+ return lines.join("\n");
40
+ };
41
+
42
+ export const renderHook = (shell: RcShell) => {
43
+ if (shell === "pwsh") {
44
+ return [
45
+ "function getrouter {",
46
+ " $cmd = Get-Command getrouter -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1",
47
+ " if ($null -ne $cmd) {",
48
+ " & $cmd.Source @args",
49
+ " }",
50
+ " $exitCode = $LASTEXITCODE",
51
+ " if ($exitCode -ne 0) {",
52
+ " return $exitCode",
53
+ " }",
54
+ ' if ($args.Count -gt 0 -and ($args[0] -eq "codex" -or $args[0] -eq "claude")) {',
55
+ ' $configDir = if ($env:GETROUTER_CONFIG_DIR) { $env:GETROUTER_CONFIG_DIR } else { Join-Path $HOME ".getrouter" }',
56
+ ' $envPath = Join-Path $configDir "env.ps1"',
57
+ " if (Test-Path $envPath) {",
58
+ " . $envPath",
59
+ " }",
60
+ " }",
61
+ " return $exitCode",
62
+ "}",
63
+ "",
64
+ ].join("\n");
65
+ }
66
+
67
+ if (shell === "fish") {
68
+ return [
69
+ "function getrouter",
70
+ " command getrouter $argv",
71
+ " set -l exit_code $status",
72
+ " if test $exit_code -ne 0",
73
+ " return $exit_code",
74
+ " end",
75
+ " if test (count $argv) -gt 0",
76
+ " switch $argv[1]",
77
+ " case codex claude",
78
+ " set -l config_dir $GETROUTER_CONFIG_DIR",
79
+ ' if test -z "$config_dir"',
80
+ ' set config_dir "$HOME/.getrouter"',
81
+ " end",
82
+ ' set -l env_path "$config_dir/env.sh"',
83
+ ' if test -f "$env_path"',
84
+ ' source "$env_path"',
85
+ " end",
86
+ " end",
87
+ " end",
88
+ " return $exit_code",
89
+ "end",
90
+ "",
91
+ ].join("\n");
92
+ }
93
+
94
+ return [
95
+ "getrouter() {",
96
+ ' command getrouter "$@"',
97
+ " local exit_code=$?",
98
+ " if [ $exit_code -ne 0 ]; then",
99
+ " return $exit_code",
100
+ " fi",
101
+ ' case "$1" in',
102
+ " codex|claude)",
103
+ ` local config_dir="\${GETROUTER_CONFIG_DIR:-$HOME/.getrouter}"`,
104
+ ' local env_path="$config_dir/env.sh"',
105
+ ' if [ -f "$env_path" ]; then',
106
+ ' source "$env_path"',
107
+ " fi",
108
+ " ;;",
109
+ " esac",
110
+ " return $exit_code",
111
+ "}",
112
+ "",
113
+ ].join("\n");
114
+ };
115
+
116
+ export const getEnvFilePath = (shell: EnvShell, configDir: string) =>
117
+ path.join(configDir, shell === "ps1" ? "env.ps1" : "env.sh");
118
+
119
+ export const getHookFilePath = (shell: RcShell, configDir: string) => {
120
+ if (shell === "pwsh") return path.join(configDir, "hook.ps1");
121
+ if (shell === "fish") return path.join(configDir, "hook.fish");
122
+ return path.join(configDir, "hook.sh");
123
+ };
124
+
125
+ export const writeEnvFile = (filePath: string, content: string) => {
126
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
127
+ fs.writeFileSync(filePath, content, "utf8");
128
+ if (process.platform !== "win32") {
129
+ fs.chmodSync(filePath, 0o600);
130
+ }
131
+ };
132
+
133
+ export const resolveShellRcPath = (shell: RcShell, homeDir: string) => {
134
+ if (shell === "zsh") return path.join(homeDir, ".zshrc");
135
+ if (shell === "bash") return path.join(homeDir, ".bashrc");
136
+ if (shell === "fish") return path.join(homeDir, ".config/fish/config.fish");
137
+ if (shell === "pwsh") {
138
+ if (process.platform === "win32") {
139
+ return path.join(
140
+ homeDir,
141
+ "Documents/PowerShell/Microsoft.PowerShell_profile.ps1",
142
+ );
143
+ }
144
+ return path.join(
145
+ homeDir,
146
+ ".config/powershell/Microsoft.PowerShell_profile.ps1",
147
+ );
148
+ }
149
+ return null;
150
+ };
151
+
152
+ export const resolveEnvShell = (shell: RcShell): EnvShell =>
153
+ shell === "pwsh" ? "ps1" : "sh";
154
+
155
+ export const detectShell = (): RcShell => {
156
+ const shellPath = process.env.SHELL;
157
+ if (shellPath) {
158
+ const name = shellPath.split("/").pop()?.toLowerCase();
159
+ if (
160
+ name === "zsh" ||
161
+ name === "bash" ||
162
+ name === "fish" ||
163
+ name === "pwsh"
164
+ ) {
165
+ return name;
166
+ }
167
+ }
168
+ if (process.platform === "win32") return "pwsh";
169
+ return "bash";
170
+ };
171
+
172
+ export const applyEnvVars = (vars: EnvVars) => {
173
+ if (vars.openaiBaseUrl) process.env.OPENAI_BASE_URL = vars.openaiBaseUrl;
174
+ if (vars.openaiApiKey) process.env.OPENAI_API_KEY = vars.openaiApiKey;
175
+ if (vars.anthropicBaseUrl) {
176
+ process.env.ANTHROPIC_BASE_URL = vars.anthropicBaseUrl;
177
+ }
178
+ if (vars.anthropicApiKey) {
179
+ process.env.ANTHROPIC_API_KEY = vars.anthropicApiKey;
180
+ }
181
+ };
182
+
183
+ export const formatSourceLine = (shell: EnvShell, envPath: string) =>
184
+ shell === "ps1" ? `. ${envPath}` : `source ${envPath}`;
185
+
186
+ export const trySourceEnv = (
187
+ shell: RcShell,
188
+ envShell: EnvShell,
189
+ envPath: string,
190
+ ) => {
191
+ try {
192
+ if (envShell === "ps1") {
193
+ execSync(`pwsh -NoProfile -Command ". '${envPath}'"`, {
194
+ stdio: "ignore",
195
+ });
196
+ return;
197
+ }
198
+ const command = shell === "fish" ? "source" : "source";
199
+ execSync(`${shell} -c "${command} '${envPath}'"`, {
200
+ stdio: "ignore",
201
+ });
202
+ } catch {
203
+ // Best-effort: ignore failures and let the caller print instructions.
204
+ }
205
+ };
206
+
207
+ export const appendRcIfMissing = (rcPath: string, line: string) => {
208
+ let content = "";
209
+ if (fs.existsSync(rcPath)) {
210
+ content = fs.readFileSync(rcPath, "utf8");
211
+ if (content.includes(line)) return false;
212
+ }
213
+ const prefix = content && !content.endsWith("\n") ? "\n" : "";
214
+ fs.mkdirSync(path.dirname(rcPath), { recursive: true });
215
+ fs.writeFileSync(rcPath, `${content}${prefix}${line}\n`, "utf8");
216
+ return true;
217
+ };
218
+
219
+ export const resolveConfigDir = () =>
220
+ process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");