@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.
- package/.github/workflows/ci.yml +19 -0
- package/AGENTS.md +78 -0
- package/README.ja.md +116 -0
- package/README.md +116 -0
- package/README.zh-cn.md +116 -0
- package/biome.json +10 -0
- package/bun.lock +397 -0
- package/dist/bin.mjs +1422 -0
- package/docs/plans/2026-01-01-getrouter-cli-config-command-plan.md +231 -0
- package/docs/plans/2026-01-01-getrouter-cli-config-core-plan.md +307 -0
- package/docs/plans/2026-01-01-getrouter-cli-design.md +106 -0
- package/docs/plans/2026-01-01-getrouter-cli-scaffold-plan.md +327 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-design.md +68 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-device-design.md +73 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-device-plan.md +411 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-plan.md +435 -0
- package/docs/plans/2026-01-02-getrouter-cli-http-client-plan.md +235 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-create-update-output-design.md +24 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-create-update-output-plan.md +141 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-delete-output-design.md +22 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-delete-output-plan.md +122 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-get-output-design.md +23 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-get-output-plan.md +141 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-interactive-design.md +28 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-interactive-plan.md +247 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-output-design.md +31 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-output-plan.md +187 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-subscription-design.md +52 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-subscription-plan.md +306 -0
- package/docs/plans/2026-01-02-getrouter-cli-setup-env-design.md +67 -0
- package/docs/plans/2026-01-02-getrouter-cli-setup-env-plan.md +441 -0
- package/docs/plans/2026-01-02-getrouter-cli-subscription-output-design.md +34 -0
- package/docs/plans/2026-01-02-getrouter-cli-subscription-output-plan.md +157 -0
- package/docs/plans/2026-01-03-bun-migration-plan.md +103 -0
- package/docs/plans/2026-01-03-cli-emoji-output.md +45 -0
- package/docs/plans/2026-01-03-cli-english-output.md +123 -0
- package/docs/plans/2026-01-03-cli-simplify-design.md +62 -0
- package/docs/plans/2026-01-03-cli-simplify-implementation.md +468 -0
- package/docs/plans/2026-01-03-readme-command-descriptions.md +116 -0
- package/docs/plans/2026-01-03-tsdown-migration-plan.md +75 -0
- package/docs/plans/2026-01-04-cli-docs-cleanup-design.md +49 -0
- package/docs/plans/2026-01-04-cli-docs-cleanup-plan.md +126 -0
- package/docs/plans/2026-01-04-codex-multistep-design.md +76 -0
- package/docs/plans/2026-01-04-codex-multistep-plan.md +240 -0
- package/docs/plans/2026-01-04-env-hook-design.md +48 -0
- package/docs/plans/2026-01-04-env-hook-plan.md +173 -0
- package/docs/plans/2026-01-04-models-keys-fuzzy-design.md +75 -0
- package/docs/plans/2026-01-04-models-keys-fuzzy-implementation.md +704 -0
- package/package.json +37 -0
- package/src/.gitkeep +0 -0
- package/src/bin.ts +4 -0
- package/src/cli.ts +12 -0
- package/src/cmd/auth.ts +44 -0
- package/src/cmd/claude.ts +10 -0
- package/src/cmd/codex.ts +119 -0
- package/src/cmd/config-helpers.ts +16 -0
- package/src/cmd/config.ts +31 -0
- package/src/cmd/env.ts +103 -0
- package/src/cmd/index.ts +20 -0
- package/src/cmd/keys.ts +207 -0
- package/src/cmd/models.ts +48 -0
- package/src/cmd/status.ts +106 -0
- package/src/cmd/usages.ts +29 -0
- package/src/core/api/client.ts +79 -0
- package/src/core/auth/device.ts +105 -0
- package/src/core/auth/index.ts +37 -0
- package/src/core/config/fs.ts +13 -0
- package/src/core/config/index.ts +37 -0
- package/src/core/config/paths.ts +5 -0
- package/src/core/config/redact.ts +18 -0
- package/src/core/config/types.ts +23 -0
- package/src/core/http/errors.ts +32 -0
- package/src/core/http/request.ts +41 -0
- package/src/core/http/url.ts +12 -0
- package/src/core/interactive/clipboard.ts +61 -0
- package/src/core/interactive/codex.ts +75 -0
- package/src/core/interactive/fuzzy.ts +64 -0
- package/src/core/interactive/keys.ts +164 -0
- package/src/core/output/table.ts +34 -0
- package/src/core/output/usages.ts +75 -0
- package/src/core/paths.ts +4 -0
- package/src/core/setup/codex.ts +129 -0
- package/src/core/setup/env.ts +220 -0
- package/src/core/usages/aggregate.ts +69 -0
- package/src/generated/router/dashboard/v1/index.ts +1104 -0
- package/src/index.ts +1 -0
- package/tests/.gitkeep +0 -0
- package/tests/auth/device.test.ts +75 -0
- package/tests/auth/status.test.ts +64 -0
- package/tests/cli.test.ts +31 -0
- package/tests/cmd/auth.test.ts +90 -0
- package/tests/cmd/claude.test.ts +132 -0
- package/tests/cmd/codex.test.ts +147 -0
- package/tests/cmd/config-helpers.test.ts +18 -0
- package/tests/cmd/config.test.ts +56 -0
- package/tests/cmd/keys.test.ts +163 -0
- package/tests/cmd/models.test.ts +63 -0
- package/tests/cmd/status.test.ts +82 -0
- package/tests/cmd/usages.test.ts +42 -0
- package/tests/config/fs.test.ts +14 -0
- package/tests/config/index.test.ts +63 -0
- package/tests/config/paths.test.ts +10 -0
- package/tests/config/redact.test.ts +17 -0
- package/tests/config/types.test.ts +10 -0
- package/tests/core/api/client.test.ts +92 -0
- package/tests/core/interactive/clipboard.test.ts +44 -0
- package/tests/core/interactive/codex.test.ts +17 -0
- package/tests/core/interactive/fuzzy.test.ts +30 -0
- package/tests/core/setup/codex.test.ts +38 -0
- package/tests/core/setup/env.test.ts +84 -0
- package/tests/core/usages/aggregate.test.ts +55 -0
- package/tests/http/errors.test.ts +15 -0
- package/tests/http/request.test.ts +82 -0
- package/tests/http/url.test.ts +17 -0
- package/tests/output/table.test.ts +29 -0
- package/tests/output/usages.test.ts +71 -0
- package/tests/paths.test.ts +9 -0
- package/tsconfig.json +13 -0
- package/tsdown.config.ts +5 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
# Setup Env Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add `getrouter setup env` to generate environment variable configuration (OpenAI/Anthropic) with optional print/install behavior.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Introduce a small `core/setup/env` helper to render env content, write env files, and manage shell rc installation. Wire a new `setup env` command that selects an API key (explicit `--key` or interactive), fetches key details, and writes/prints env configuration.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Commander, Vitest, Node `fs/path/os`.
|
|
10
|
+
|
|
11
|
+
**Skills:** @superpowers:test-driven-development, @superpowers:systematic-debugging (if failures)
|
|
12
|
+
|
|
13
|
+
### Task 1: Add failing tests for setup env
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Create: `tests/cmd/setup.test.ts`
|
|
17
|
+
- Create: `tests/core/setup/env.test.ts`
|
|
18
|
+
|
|
19
|
+
**Step 1: Write failing core helper tests**
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { describe, it, expect } from "vitest";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import {
|
|
27
|
+
renderEnv,
|
|
28
|
+
writeEnvFile,
|
|
29
|
+
getEnvFilePath,
|
|
30
|
+
resolveShellRcPath,
|
|
31
|
+
appendRcIfMissing,
|
|
32
|
+
} from "../../src/core/setup/env";
|
|
33
|
+
|
|
34
|
+
const vars = {
|
|
35
|
+
openaiBaseUrl: "https://api.getrouter.dev/v1",
|
|
36
|
+
openaiApiKey: "key-123",
|
|
37
|
+
anthropicBaseUrl: "https://api.getrouter.dev/v1",
|
|
38
|
+
anthropicApiKey: "key-123",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("setup env helpers", () => {
|
|
42
|
+
it("renders sh env", () => {
|
|
43
|
+
const output = renderEnv("sh", vars);
|
|
44
|
+
expect(output).toContain("export OPENAI_BASE_URL=https://api.getrouter.dev/v1");
|
|
45
|
+
expect(output).toContain("export ANTHROPIC_API_KEY=key-123");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders ps1 env", () => {
|
|
49
|
+
const output = renderEnv("ps1", vars);
|
|
50
|
+
expect(output).toContain('$env:OPENAI_BASE_URL="https://api.getrouter.dev/v1"');
|
|
51
|
+
expect(output).toContain('$env:ANTHROPIC_API_KEY="key-123"');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("writes env file", () => {
|
|
55
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-env-"));
|
|
56
|
+
const filePath = getEnvFilePath("sh", dir);
|
|
57
|
+
writeEnvFile(filePath, "hello");
|
|
58
|
+
expect(fs.readFileSync(filePath, "utf8")).toBe("hello");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("resolves shell rc paths", () => {
|
|
62
|
+
expect(resolveShellRcPath("zsh", "/tmp")).toBe("/tmp/.zshrc");
|
|
63
|
+
expect(resolveShellRcPath("bash", "/tmp")).toBe("/tmp/.bashrc");
|
|
64
|
+
expect(resolveShellRcPath("fish", "/tmp")).toBe(
|
|
65
|
+
"/tmp/.config/fish/config.fish"
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("appends rc line once", () => {
|
|
70
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-rc-"));
|
|
71
|
+
const rcPath = path.join(dir, "rc");
|
|
72
|
+
const line = "source ~/.getrouter/env.sh";
|
|
73
|
+
fs.writeFileSync(rcPath, line + "\n");
|
|
74
|
+
expect(appendRcIfMissing(rcPath, line)).toBe(false);
|
|
75
|
+
expect(appendRcIfMissing(rcPath, line)).toBe(false);
|
|
76
|
+
const content = fs.readFileSync(rcPath, "utf8");
|
|
77
|
+
expect(content.split(line).length - 1).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Step 2: Write failing command tests**
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
86
|
+
import fs from "node:fs";
|
|
87
|
+
import os from "node:os";
|
|
88
|
+
import path from "node:path";
|
|
89
|
+
import { createProgram } from "../../src/cli";
|
|
90
|
+
import { createApiClients } from "../../src/core/api/client";
|
|
91
|
+
import { getEnvFilePath } from "../../src/core/setup/env";
|
|
92
|
+
|
|
93
|
+
vi.mock("../../src/core/api/client", () => ({
|
|
94
|
+
createApiClients: vi.fn(),
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
const makeDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
98
|
+
const mockConsumer = { id: "c1", apiKey: "key-123" };
|
|
99
|
+
|
|
100
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
101
|
+
const setStdinTTY = (value: boolean) => {
|
|
102
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
103
|
+
value,
|
|
104
|
+
configurable: true,
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
setStdinTTY(originalIsTTY);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("setup env", () => {
|
|
113
|
+
it("prints env content when --print is set", async () => {
|
|
114
|
+
const dir = makeDir();
|
|
115
|
+
process.env.GETROUTER_CONFIG_DIR = dir;
|
|
116
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
117
|
+
consumerService: { GetConsumer: vi.fn().mockResolvedValue(mockConsumer) },
|
|
118
|
+
subscriptionService: {} as any,
|
|
119
|
+
authService: {} as any,
|
|
120
|
+
});
|
|
121
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
122
|
+
const program = createProgram();
|
|
123
|
+
await program.parseAsync([
|
|
124
|
+
"node",
|
|
125
|
+
"getrouter",
|
|
126
|
+
"setup",
|
|
127
|
+
"env",
|
|
128
|
+
"--key",
|
|
129
|
+
"c1",
|
|
130
|
+
"--print",
|
|
131
|
+
"--shell",
|
|
132
|
+
"bash",
|
|
133
|
+
]);
|
|
134
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
135
|
+
expect(output).toContain("OPENAI_BASE_URL");
|
|
136
|
+
expect(fs.existsSync(getEnvFilePath("sh", dir))).toBe(false);
|
|
137
|
+
log.mockRestore();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("writes env file by default", async () => {
|
|
141
|
+
const dir = makeDir();
|
|
142
|
+
process.env.GETROUTER_CONFIG_DIR = dir;
|
|
143
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
144
|
+
consumerService: { GetConsumer: vi.fn().mockResolvedValue(mockConsumer) },
|
|
145
|
+
subscriptionService: {} as any,
|
|
146
|
+
authService: {} as any,
|
|
147
|
+
});
|
|
148
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
149
|
+
const program = createProgram();
|
|
150
|
+
await program.parseAsync([
|
|
151
|
+
"node",
|
|
152
|
+
"getrouter",
|
|
153
|
+
"setup",
|
|
154
|
+
"env",
|
|
155
|
+
"--key",
|
|
156
|
+
"c1",
|
|
157
|
+
"--shell",
|
|
158
|
+
"bash",
|
|
159
|
+
]);
|
|
160
|
+
const content = fs.readFileSync(getEnvFilePath("sh", dir), "utf8");
|
|
161
|
+
expect(content).toContain("OPENAI_BASE_URL");
|
|
162
|
+
log.mockRestore();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("fails when no key in non-tty", async () => {
|
|
166
|
+
setStdinTTY(false);
|
|
167
|
+
const program = createProgram();
|
|
168
|
+
await expect(
|
|
169
|
+
program.parseAsync(["node", "getrouter", "setup", "env"])
|
|
170
|
+
).rejects.toThrow("缺少 key id");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Step 3: Run tests to verify they fail**
|
|
176
|
+
|
|
177
|
+
Run: `npm test -- tests/cmd/setup.test.ts tests/core/setup/env.test.ts`
|
|
178
|
+
Expected: FAIL (module/command missing).
|
|
179
|
+
|
|
180
|
+
**Step 4: Commit failing tests**
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
git add tests/cmd/setup.test.ts tests/core/setup/env.test.ts
|
|
184
|
+
git commit -m "test: cover setup env"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Task 2: Implement setup env helpers
|
|
188
|
+
|
|
189
|
+
**Files:**
|
|
190
|
+
- Create: `src/core/setup/env.ts`
|
|
191
|
+
|
|
192
|
+
**Step 1: Implement helper module**
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import fs from "node:fs";
|
|
196
|
+
import path from "node:path";
|
|
197
|
+
import os from "node:os";
|
|
198
|
+
|
|
199
|
+
type EnvVars = {
|
|
200
|
+
openaiBaseUrl: string;
|
|
201
|
+
openaiApiKey: string;
|
|
202
|
+
anthropicBaseUrl: string;
|
|
203
|
+
anthropicApiKey: string;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
type EnvShell = "sh" | "ps1";
|
|
207
|
+
|
|
208
|
+
type RcShell = "zsh" | "bash" | "fish" | "pwsh";
|
|
209
|
+
|
|
210
|
+
export const renderEnv = (shell: EnvShell, vars: EnvVars) => {
|
|
211
|
+
if (shell === "ps1") {
|
|
212
|
+
return [
|
|
213
|
+
`$env:OPENAI_BASE_URL=\"${vars.openaiBaseUrl}\"`,
|
|
214
|
+
`$env:OPENAI_API_KEY=\"${vars.openaiApiKey}\"`,
|
|
215
|
+
`$env:ANTHROPIC_BASE_URL=\"${vars.anthropicBaseUrl}\"`,
|
|
216
|
+
`$env:ANTHROPIC_API_KEY=\"${vars.anthropicApiKey}\"`,
|
|
217
|
+
"",
|
|
218
|
+
].join("\n");
|
|
219
|
+
}
|
|
220
|
+
return [
|
|
221
|
+
`export OPENAI_BASE_URL=${vars.openaiBaseUrl}`,
|
|
222
|
+
`export OPENAI_API_KEY=${vars.openaiApiKey}`,
|
|
223
|
+
`export ANTHROPIC_BASE_URL=${vars.anthropicBaseUrl}`,
|
|
224
|
+
`export ANTHROPIC_API_KEY=${vars.anthropicApiKey}`,
|
|
225
|
+
"",
|
|
226
|
+
].join("\n");
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export const getEnvFilePath = (shell: EnvShell, configDir: string) =>
|
|
230
|
+
path.join(configDir, shell === "ps1" ? "env.ps1" : "env.sh");
|
|
231
|
+
|
|
232
|
+
export const writeEnvFile = (filePath: string, content: string) => {
|
|
233
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
234
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
235
|
+
if (process.platform !== "win32") {
|
|
236
|
+
fs.chmodSync(filePath, 0o600);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export const resolveShellRcPath = (shell: RcShell, homeDir: string) => {
|
|
241
|
+
if (shell === "zsh") return path.join(homeDir, ".zshrc");
|
|
242
|
+
if (shell === "bash") return path.join(homeDir, ".bashrc");
|
|
243
|
+
if (shell === "fish") return path.join(homeDir, ".config/fish/config.fish");
|
|
244
|
+
if (shell === "pwsh") {
|
|
245
|
+
if (process.platform === "win32") {
|
|
246
|
+
return path.join(
|
|
247
|
+
homeDir,
|
|
248
|
+
"Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return path.join(homeDir, ".config/powershell/Microsoft.PowerShell_profile.ps1");
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export const appendRcIfMissing = (rcPath: string, line: string) => {
|
|
257
|
+
let content = "";
|
|
258
|
+
if (fs.existsSync(rcPath)) {
|
|
259
|
+
content = fs.readFileSync(rcPath, "utf8");
|
|
260
|
+
if (content.includes(line)) return false;
|
|
261
|
+
}
|
|
262
|
+
const prefix = content && !content.endsWith("\n") ? "\n" : "";
|
|
263
|
+
fs.mkdirSync(path.dirname(rcPath), { recursive: true });
|
|
264
|
+
fs.writeFileSync(rcPath, content + prefix + line + "\n", "utf8");
|
|
265
|
+
return true;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
export const resolveConfigDir = () =>
|
|
269
|
+
process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Step 2: Run helper tests to verify pass**
|
|
273
|
+
|
|
274
|
+
Run: `npm test -- tests/core/setup/env.test.ts`
|
|
275
|
+
Expected: PASS
|
|
276
|
+
|
|
277
|
+
**Step 3: Commit helper implementation**
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
git add src/core/setup/env.ts tests/core/setup/env.test.ts
|
|
281
|
+
git commit -m "feat: add setup env helpers"
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Task 3: Wire setup env command
|
|
285
|
+
|
|
286
|
+
**Files:**
|
|
287
|
+
- Create: `src/cmd/setup.ts`
|
|
288
|
+
- Modify: `src/cmd/index.ts`
|
|
289
|
+
- Modify: `tests/cmd/setup.test.ts`
|
|
290
|
+
|
|
291
|
+
**Step 1: Implement setup env command**
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
import { Command } from "commander";
|
|
295
|
+
import { createApiClients } from "../core/api/client";
|
|
296
|
+
import { selectConsumer } from "../core/interactive/keys";
|
|
297
|
+
import {
|
|
298
|
+
renderEnv,
|
|
299
|
+
getEnvFilePath,
|
|
300
|
+
writeEnvFile,
|
|
301
|
+
resolveShellRcPath,
|
|
302
|
+
appendRcIfMissing,
|
|
303
|
+
resolveConfigDir,
|
|
304
|
+
} from "../core/setup/env";
|
|
305
|
+
|
|
306
|
+
const BASE_URL = "https://api.getrouter.dev/v1";
|
|
307
|
+
|
|
308
|
+
type SetupEnvOptions = {
|
|
309
|
+
key?: string;
|
|
310
|
+
print?: boolean;
|
|
311
|
+
install?: boolean;
|
|
312
|
+
shell?: string;
|
|
313
|
+
json?: boolean;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const normalizeShell = (value?: string) => {
|
|
317
|
+
if (!value) return undefined;
|
|
318
|
+
const v = value.toLowerCase();
|
|
319
|
+
if (["zsh", "bash", "fish", "pwsh"].includes(v)) return v;
|
|
320
|
+
throw new Error("Unknown shell");
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const resolveEnvShell = (shell?: string) => {
|
|
324
|
+
if (shell === "pwsh") return "ps1" as const;
|
|
325
|
+
if (process.platform === "win32") return "ps1" as const;
|
|
326
|
+
return "sh" as const;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
export const registerSetupCommands = (program: Command) => {
|
|
330
|
+
const setup = program.command("setup").description("Setup CLI environment");
|
|
331
|
+
|
|
332
|
+
setup
|
|
333
|
+
.command("env")
|
|
334
|
+
.description("Generate environment variables")
|
|
335
|
+
.option("--key <id>")
|
|
336
|
+
.option("--print", "Print env to stdout")
|
|
337
|
+
.option("--install", "Install into shell rc")
|
|
338
|
+
.option("--shell <shell>", "zsh|bash|fish|pwsh")
|
|
339
|
+
.option("--json", "Output JSON")
|
|
340
|
+
.action(async (options: SetupEnvOptions) => {
|
|
341
|
+
const shell = normalizeShell(options.shell);
|
|
342
|
+
const configDir = resolveConfigDir();
|
|
343
|
+
const envShell = resolveEnvShell(shell);
|
|
344
|
+
const { consumerService } = createApiClients({});
|
|
345
|
+
let keyId = options.key;
|
|
346
|
+
if (!keyId) {
|
|
347
|
+
if (!process.stdin.isTTY) {
|
|
348
|
+
throw new Error("缺少 key id");
|
|
349
|
+
}
|
|
350
|
+
const selected = await selectConsumer(consumerService);
|
|
351
|
+
if (!selected?.id) return;
|
|
352
|
+
keyId = selected.id;
|
|
353
|
+
}
|
|
354
|
+
const consumer = await consumerService.GetConsumer({ id: keyId });
|
|
355
|
+
if (!consumer?.apiKey) {
|
|
356
|
+
throw new Error("API key 不存在,请先创建或重新选择。");
|
|
357
|
+
}
|
|
358
|
+
const content = renderEnv(envShell, {
|
|
359
|
+
openaiBaseUrl: BASE_URL,
|
|
360
|
+
openaiApiKey: consumer.apiKey,
|
|
361
|
+
anthropicBaseUrl: BASE_URL,
|
|
362
|
+
anthropicApiKey: consumer.apiKey,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (options.print) {
|
|
366
|
+
if (options.json) {
|
|
367
|
+
console.log(JSON.stringify({ content }, null, 2));
|
|
368
|
+
} else {
|
|
369
|
+
console.log(content);
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const envPath = getEnvFilePath(envShell, configDir);
|
|
375
|
+
writeEnvFile(envPath, content);
|
|
376
|
+
|
|
377
|
+
let installed = false;
|
|
378
|
+
if (options.install) {
|
|
379
|
+
const homeDir = require("node:os").homedir();
|
|
380
|
+
const rcPath = resolveShellRcPath(shell ?? "bash", homeDir);
|
|
381
|
+
if (rcPath) {
|
|
382
|
+
const sourceLine =
|
|
383
|
+
envShell === "ps1"
|
|
384
|
+
? `. ${envPath}`
|
|
385
|
+
: `source ${envPath}`;
|
|
386
|
+
installed = appendRcIfMissing(rcPath, sourceLine);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (options.json) {
|
|
391
|
+
console.log(
|
|
392
|
+
JSON.stringify(
|
|
393
|
+
{ path: envPath, installed, keyId },
|
|
394
|
+
null,
|
|
395
|
+
2
|
|
396
|
+
)
|
|
397
|
+
);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
console.log("To configure your shell, run:");
|
|
402
|
+
console.log(envShell === "ps1" ? `. ${envPath}` : `source ${envPath}`);
|
|
403
|
+
});
|
|
404
|
+
};
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**Step 2: Register the command**
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
import { registerSetupCommands } from "./setup";
|
|
411
|
+
|
|
412
|
+
// inside registerCommands
|
|
413
|
+
registerSetupCommands(program);
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
**Step 3: Run command tests to verify pass**
|
|
417
|
+
|
|
418
|
+
Run: `npm test -- tests/cmd/setup.test.ts`
|
|
419
|
+
Expected: PASS
|
|
420
|
+
|
|
421
|
+
**Step 4: Commit command wiring**
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
git add src/cmd/setup.ts src/cmd/index.ts tests/cmd/setup.test.ts
|
|
425
|
+
git commit -m "feat: add setup env command"
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Task 4: Full test run
|
|
429
|
+
|
|
430
|
+
**Step 1: Run full test suite**
|
|
431
|
+
|
|
432
|
+
Run: `npm test`
|
|
433
|
+
Expected: PASS
|
|
434
|
+
|
|
435
|
+
**Step 2: Commit (if needed)**
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
git status -sb
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
If clean, no commit needed.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# getrouter CLI Subscription show 表格输出设计
|
|
2
|
+
|
|
3
|
+
日期:2026-01-02
|
|
4
|
+
状态:已确认
|
|
5
|
+
|
|
6
|
+
## 目标
|
|
7
|
+
- `subscription show` 默认输出改为单行表格,与 `keys list/get` 风格一致。
|
|
8
|
+
- `--json` 行为不变。
|
|
9
|
+
|
|
10
|
+
## 输出格式
|
|
11
|
+
- 列顺序:`PLAN | STATUS | START_AT | END_AT | REQUEST_PER_MINUTE | TOKEN_PER_MINUTE`
|
|
12
|
+
- 数据来源:
|
|
13
|
+
- `PLAN` → `plan.name`
|
|
14
|
+
- `STATUS` → `status`
|
|
15
|
+
- `START_AT` → `startAt`
|
|
16
|
+
- `END_AT` → `endAt`
|
|
17
|
+
- `REQUEST_PER_MINUTE` → `plan.requestPerMinute`
|
|
18
|
+
- `TOKEN_PER_MINUTE` → `plan.tokenPerMinute`
|
|
19
|
+
- 空值显示 `-`;超长字段按 `renderTable` 规则截断(默认 32)。
|
|
20
|
+
|
|
21
|
+
## 行为细节
|
|
22
|
+
- 默认人类可读:表头 + 1 行数据。
|
|
23
|
+
- `--json`:输出原始结构。
|
|
24
|
+
- 未订阅(404 或空响应):默认输出“未订阅”(不渲染表格)。
|
|
25
|
+
- 其它命令输出不变。
|
|
26
|
+
|
|
27
|
+
## 错误处理
|
|
28
|
+
- 401/403:提示 `getrouter auth login`(沿用既有策略)。
|
|
29
|
+
- 其它错误:沿用现有 JSON 与非 JSON 的错误输出策略。
|
|
30
|
+
|
|
31
|
+
## 测试策略
|
|
32
|
+
- `subscription show` 默认输出包含表头与列顺序。
|
|
33
|
+
- `--json` 输出结构不变。
|
|
34
|
+
- 未订阅场景输出“未订阅”。
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Subscription Show Table Output Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Change `subscription show` default output to a single-row table while keeping `--json` behavior unchanged and handling unsubscribed responses gracefully.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Add a table-rendering path in `cmd/subscription` similar to `keys list/get`, reusing `renderTable` with fixed headers and a single row. Preserve JSON output and handle 404/empty responses with a plain “未订阅” message in non-JSON mode.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Commander, Vitest.
|
|
10
|
+
|
|
11
|
+
**Skills:** @superpowers:test-driven-development, @superpowers:systematic-debugging (if failures)
|
|
12
|
+
|
|
13
|
+
### Task 1: Add failing tests for table output and unsubscribed behavior
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Modify: `tests/cmd/subscription.test.ts`
|
|
17
|
+
|
|
18
|
+
**Step 1: Write the failing tests**
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
it("prints table header in default mode", async () => {
|
|
22
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
23
|
+
subscriptionService: {
|
|
24
|
+
CurrentSubscription: vi.fn().mockResolvedValue(mockSubscription),
|
|
25
|
+
},
|
|
26
|
+
consumerService: {} as any,
|
|
27
|
+
});
|
|
28
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
29
|
+
const program = createProgram();
|
|
30
|
+
await program.parseAsync(["node", "getrouter", "subscription", "show"]);
|
|
31
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
32
|
+
expect(output).toContain("PLAN");
|
|
33
|
+
expect(output).toContain("STATUS");
|
|
34
|
+
expect(output).toContain("START_AT");
|
|
35
|
+
expect(output).toContain("END_AT");
|
|
36
|
+
expect(output).toContain("REQUEST_PER_MINUTE");
|
|
37
|
+
expect(output).toContain("TOKEN_PER_MINUTE");
|
|
38
|
+
log.mockRestore();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("prints 未订阅 when subscription is missing", async () => {
|
|
42
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
43
|
+
subscriptionService: {
|
|
44
|
+
CurrentSubscription: vi.fn().mockResolvedValue(null),
|
|
45
|
+
},
|
|
46
|
+
consumerService: {} as any,
|
|
47
|
+
});
|
|
48
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
49
|
+
const program = createProgram();
|
|
50
|
+
await program.parseAsync(["node", "getrouter", "subscription", "show"]);
|
|
51
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
52
|
+
expect(output).toContain("未订阅");
|
|
53
|
+
log.mockRestore();
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Step 2: Run test to verify it fails**
|
|
58
|
+
|
|
59
|
+
Run: `npm test -- tests/cmd/subscription.test.ts`
|
|
60
|
+
|
|
61
|
+
Expected: FAIL because default output is currently key=value and no “未订阅” handling.
|
|
62
|
+
|
|
63
|
+
**Step 3: Commit the failing tests**
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git add tests/cmd/subscription.test.ts
|
|
67
|
+
git commit -m "test: cover subscription table output"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Task 2: Render subscription output as table
|
|
71
|
+
|
|
72
|
+
**Files:**
|
|
73
|
+
- Modify: `src/cmd/subscription.ts`
|
|
74
|
+
- Test: `tests/cmd/subscription.test.ts`
|
|
75
|
+
|
|
76
|
+
**Step 1: Add headers/row helpers and table output path**
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { renderTable } from "../core/output/table";
|
|
80
|
+
|
|
81
|
+
const subscriptionHeaders = [
|
|
82
|
+
"PLAN",
|
|
83
|
+
"STATUS",
|
|
84
|
+
"START_AT",
|
|
85
|
+
"END_AT",
|
|
86
|
+
"REQUEST_PER_MINUTE",
|
|
87
|
+
"TOKEN_PER_MINUTE",
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const subscriptionRow = (subscription: any) => [
|
|
91
|
+
String(subscription?.plan?.name ?? ""),
|
|
92
|
+
String(subscription?.status ?? ""),
|
|
93
|
+
String(subscription?.startAt ?? ""),
|
|
94
|
+
String(subscription?.endAt ?? ""),
|
|
95
|
+
String(subscription?.plan?.requestPerMinute ?? ""),
|
|
96
|
+
String(subscription?.plan?.tokenPerMinute ?? ""),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const outputSubscriptionTable = (subscription: any) => {
|
|
100
|
+
console.log(renderTable(subscriptionHeaders, [subscriptionRow(subscription)]));
|
|
101
|
+
};
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Step 2: Handle missing subscription in non-JSON mode**
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
if (!subscription) {
|
|
108
|
+
if (json) {
|
|
109
|
+
console.log(JSON.stringify(subscription, null, 2));
|
|
110
|
+
} else {
|
|
111
|
+
console.log("未订阅");
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Step 3: Route default output to table renderer**
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
if (json) {
|
|
121
|
+
console.log(JSON.stringify(subscription, null, 2));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
outputSubscriptionTable(subscription);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Step 4: Run tests to verify pass**
|
|
128
|
+
|
|
129
|
+
Run: `npm test -- tests/cmd/subscription.test.ts`
|
|
130
|
+
|
|
131
|
+
Expected: PASS
|
|
132
|
+
|
|
133
|
+
**Step 5: Commit**
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
git add src/cmd/subscription.ts tests/cmd/subscription.test.ts
|
|
137
|
+
git commit -m "feat: render subscription output as table"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Task 3: Full test run
|
|
141
|
+
|
|
142
|
+
**Files:**
|
|
143
|
+
- None
|
|
144
|
+
|
|
145
|
+
**Step 1: Run full test suite**
|
|
146
|
+
|
|
147
|
+
Run: `npm test`
|
|
148
|
+
|
|
149
|
+
Expected: PASS
|
|
150
|
+
|
|
151
|
+
**Step 2: Commit (if needed)**
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
git status -sb
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
If clean, no commit needed.
|