@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,704 @@
|
|
|
1
|
+
# Models & Keys Fuzzy Selection Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add interactive fuzzy selection for `models` and `keys` (no `list`), keep `list` as non-interactive output, and copy selected IDs/keys to clipboard when possible.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Implement a small fuzzy ranking helper and a best-effort clipboard helper, then wire them into the `models` and `keys` commands. `models`/`keys` without `list` use the fuzzy flow; `models list`/`keys list` remain list output. Clipboard copy uses platform tools with graceful fallback.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Commander.js, prompts, Vitest, Biome.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Add fuzzy ranking + selection helpers
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Create: `src/core/interactive/fuzzy.ts`
|
|
17
|
+
- Test: `tests/core/interactive/fuzzy.test.ts`
|
|
18
|
+
|
|
19
|
+
**Step 1: Write the failing test**
|
|
20
|
+
|
|
21
|
+
Create `tests/core/interactive/fuzzy.test.ts`:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { describe, expect, it } from "vitest";
|
|
25
|
+
import { rankFuzzyChoices } from "../../src/core/interactive/fuzzy";
|
|
26
|
+
|
|
27
|
+
const choices = [
|
|
28
|
+
{ title: "gpt-5", value: "gpt-5", keywords: ["openai"] },
|
|
29
|
+
{ title: "claude-3", value: "claude-3", keywords: ["anthropic"] },
|
|
30
|
+
{ title: "gpt-4o", value: "gpt-4o", keywords: ["openai"] },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
describe("rankFuzzyChoices", () => {
|
|
34
|
+
it("ranks by fuzzy match and uses keywords", () => {
|
|
35
|
+
const ranked = rankFuzzyChoices(choices, "open");
|
|
36
|
+
expect(ranked[0]?.value).toBe("gpt-5");
|
|
37
|
+
expect(ranked[1]?.value).toBe("gpt-4o");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns all when query is empty", () => {
|
|
41
|
+
const ranked = rankFuzzyChoices(choices, "");
|
|
42
|
+
expect(ranked.map((c) => c.value)).toEqual(["gpt-5", "claude-3", "gpt-4o"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("limits results", () => {
|
|
46
|
+
const ranked = rankFuzzyChoices(choices, "g", 2);
|
|
47
|
+
expect(ranked.length).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Step 2: Run test to verify it fails**
|
|
53
|
+
|
|
54
|
+
Run: `bun run test -- tests/core/interactive/fuzzy.test.ts`
|
|
55
|
+
Expected: FAIL (module or function missing).
|
|
56
|
+
|
|
57
|
+
**Step 3: Write minimal implementation**
|
|
58
|
+
|
|
59
|
+
Create `src/core/interactive/fuzzy.ts`:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import prompts from "prompts";
|
|
63
|
+
|
|
64
|
+
export type FuzzyChoice<T> = {
|
|
65
|
+
title: string;
|
|
66
|
+
value: T;
|
|
67
|
+
keywords?: string[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const normalize = (value: string) => value.toLowerCase();
|
|
71
|
+
|
|
72
|
+
const fuzzyScore = (query: string, target: string): number | null => {
|
|
73
|
+
if (!query) return 0;
|
|
74
|
+
let score = 0;
|
|
75
|
+
let lastIndex = -1;
|
|
76
|
+
for (const ch of query) {
|
|
77
|
+
const index = target.indexOf(ch, lastIndex + 1);
|
|
78
|
+
if (index === -1) return null;
|
|
79
|
+
score += index;
|
|
80
|
+
lastIndex = index;
|
|
81
|
+
}
|
|
82
|
+
return score;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const toSearchText = <T>(choice: FuzzyChoice<T>) =>
|
|
86
|
+
normalize(
|
|
87
|
+
[choice.title, ...(choice.keywords ?? [])].join(" ").trim(),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
export const rankFuzzyChoices = <T>(
|
|
91
|
+
choices: FuzzyChoice<T>[],
|
|
92
|
+
input: string,
|
|
93
|
+
limit = 50,
|
|
94
|
+
) => {
|
|
95
|
+
const query = normalize(input.trim());
|
|
96
|
+
if (!query) return choices.slice(0, limit);
|
|
97
|
+
const ranked = choices
|
|
98
|
+
.map((choice) => {
|
|
99
|
+
const score = fuzzyScore(query, toSearchText(choice));
|
|
100
|
+
return score == null ? null : { choice, score };
|
|
101
|
+
})
|
|
102
|
+
.filter(Boolean) as { choice: FuzzyChoice<T>; score: number }[];
|
|
103
|
+
ranked.sort((a, b) => a.score - b.score || a.choice.title.localeCompare(b.choice.title));
|
|
104
|
+
return ranked.slice(0, limit).map((entry) => entry.choice);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const fuzzySelect = async <T>({
|
|
108
|
+
message,
|
|
109
|
+
choices,
|
|
110
|
+
}: {
|
|
111
|
+
message: string;
|
|
112
|
+
choices: FuzzyChoice<T>[];
|
|
113
|
+
}): Promise<T | null> => {
|
|
114
|
+
const response = await prompts({
|
|
115
|
+
type: "autocomplete",
|
|
116
|
+
name: "value",
|
|
117
|
+
message,
|
|
118
|
+
choices,
|
|
119
|
+
suggest: async (input: string, items: FuzzyChoice<T>[]) =>
|
|
120
|
+
rankFuzzyChoices(items, input),
|
|
121
|
+
});
|
|
122
|
+
if (response.value == null || response.value === "") return null;
|
|
123
|
+
return response.value as T;
|
|
124
|
+
};
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Step 4: Run test to verify it passes**
|
|
128
|
+
|
|
129
|
+
Run: `bun run test -- tests/core/interactive/fuzzy.test.ts`
|
|
130
|
+
Expected: PASS.
|
|
131
|
+
|
|
132
|
+
**Step 5: Commit**
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
git add tests/core/interactive/fuzzy.test.ts src/core/interactive/fuzzy.ts
|
|
136
|
+
git commit -m "feat: add fuzzy ranking helper"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### Task 2: Add clipboard helper
|
|
142
|
+
|
|
143
|
+
**Files:**
|
|
144
|
+
- Create: `src/core/interactive/clipboard.ts`
|
|
145
|
+
- Test: `tests/core/interactive/clipboard.test.ts`
|
|
146
|
+
|
|
147
|
+
**Step 1: Write the failing test**
|
|
148
|
+
|
|
149
|
+
Create `tests/core/interactive/clipboard.test.ts`:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { describe, expect, it, vi } from "vitest";
|
|
153
|
+
import { copyToClipboard, getClipboardCommands } from "../../src/core/interactive/clipboard";
|
|
154
|
+
|
|
155
|
+
const makeSpawn = () =>
|
|
156
|
+
vi.fn(() => {
|
|
157
|
+
const handlers: Record<string, Array<(code?: number) => void>> = {};
|
|
158
|
+
const child = {
|
|
159
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
160
|
+
on: (event: string, cb: (code?: number) => void) => {
|
|
161
|
+
handlers[event] = handlers[event] ?? [];
|
|
162
|
+
handlers[event].push(cb);
|
|
163
|
+
return child;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
queueMicrotask(() => handlers.close?.forEach((cb) => cb(0)));
|
|
167
|
+
return child;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("getClipboardCommands", () => {
|
|
171
|
+
it("returns pbcopy on darwin", () => {
|
|
172
|
+
expect(getClipboardCommands("darwin")[0]?.command).toBe("pbcopy");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("copyToClipboard", () => {
|
|
177
|
+
it("writes to clipboard with provided spawn", async () => {
|
|
178
|
+
const spawnFn = makeSpawn();
|
|
179
|
+
const ok = await copyToClipboard("hello", { platform: "darwin", spawnFn });
|
|
180
|
+
expect(ok).toBe(true);
|
|
181
|
+
expect(spawnFn).toHaveBeenCalledWith("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Step 2: Run test to verify it fails**
|
|
187
|
+
|
|
188
|
+
Run: `bun run test -- tests/core/interactive/clipboard.test.ts`
|
|
189
|
+
Expected: FAIL (module or function missing).
|
|
190
|
+
|
|
191
|
+
**Step 3: Write minimal implementation**
|
|
192
|
+
|
|
193
|
+
Create `src/core/interactive/clipboard.ts`:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { spawn } from "node:child_process";
|
|
197
|
+
|
|
198
|
+
type ClipboardCommand = {
|
|
199
|
+
command: string;
|
|
200
|
+
args: string[];
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
type CopyOptions = {
|
|
204
|
+
platform?: NodeJS.Platform;
|
|
205
|
+
spawnFn?: typeof spawn;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const getClipboardCommands = (platform: NodeJS.Platform): ClipboardCommand[] => {
|
|
209
|
+
if (platform === "darwin") return [{ command: "pbcopy", args: [] }];
|
|
210
|
+
if (platform === "win32") return [{ command: "clip", args: [] }];
|
|
211
|
+
return [
|
|
212
|
+
{ command: "wl-copy", args: [] },
|
|
213
|
+
{ command: "xclip", args: ["-selection", "clipboard"] },
|
|
214
|
+
];
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const runClipboardCommand = (
|
|
218
|
+
text: string,
|
|
219
|
+
command: ClipboardCommand,
|
|
220
|
+
spawnFn: typeof spawn,
|
|
221
|
+
): Promise<boolean> =>
|
|
222
|
+
new Promise((resolve) => {
|
|
223
|
+
const child = spawnFn(command.command, command.args, {
|
|
224
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
225
|
+
});
|
|
226
|
+
child.on("error", () => resolve(false));
|
|
227
|
+
child.on("close", (code) => resolve(code === 0));
|
|
228
|
+
child.stdin.write(text);
|
|
229
|
+
child.stdin.end();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
export const copyToClipboard = async (text: string, options: CopyOptions = {}) => {
|
|
233
|
+
if (!text) return false;
|
|
234
|
+
const platform = options.platform ?? process.platform;
|
|
235
|
+
const spawnFn = options.spawnFn ?? spawn;
|
|
236
|
+
const commands = getClipboardCommands(platform);
|
|
237
|
+
for (const command of commands) {
|
|
238
|
+
const ok = await runClipboardCommand(text, command, spawnFn);
|
|
239
|
+
if (ok) return true;
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
};
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Step 4: Run test to verify it passes**
|
|
246
|
+
|
|
247
|
+
Run: `bun run test -- tests/core/interactive/clipboard.test.ts`
|
|
248
|
+
Expected: PASS.
|
|
249
|
+
|
|
250
|
+
**Step 5: Commit**
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
git add tests/core/interactive/clipboard.test.ts src/core/interactive/clipboard.ts
|
|
254
|
+
git commit -m "feat: add clipboard helper"
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
### Task 3: Add ModelService to API client
|
|
260
|
+
|
|
261
|
+
**Files:**
|
|
262
|
+
- Modify: `src/core/api/client.ts`
|
|
263
|
+
- Modify: `tests/core/api/client.test.ts`
|
|
264
|
+
|
|
265
|
+
**Step 1: Write the failing test**
|
|
266
|
+
|
|
267
|
+
Update `tests/core/api/client.test.ts` to include model client factory:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import type { ModelService } from "../../../src/generated/router/dashboard/v1";
|
|
271
|
+
|
|
272
|
+
// inside fakeClients
|
|
273
|
+
createModelServiceClient: (handler: RequestHandler) =>
|
|
274
|
+
({
|
|
275
|
+
ListModels: () =>
|
|
276
|
+
handler(
|
|
277
|
+
{ path: "v1/dashboard/models", method: "GET", body: null },
|
|
278
|
+
{ service: "ModelService", method: "ListModels" },
|
|
279
|
+
),
|
|
280
|
+
}) as unknown as ModelService,
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Add a small assertion in the first test to ensure model service exists:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const { modelService } = createApiClients({ fetchImpl, clients: fakeClients });
|
|
287
|
+
const modelsRes = await modelService.ListModels({ pageSize: 0, pageToken: "", filter: "" });
|
|
288
|
+
const modelsPayload = modelsRes as unknown as { url: string; init: RequestInit };
|
|
289
|
+
expect(modelsPayload.url).toContain("/v1/dashboard/models");
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Step 2: Run test to verify it fails**
|
|
293
|
+
|
|
294
|
+
Run: `bun run test -- tests/core/api/client.test.ts`
|
|
295
|
+
Expected: FAIL (createModelServiceClient not wired / modelService missing).
|
|
296
|
+
|
|
297
|
+
**Step 3: Write minimal implementation**
|
|
298
|
+
|
|
299
|
+
Update `src/core/api/client.ts` to add model client wiring:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
import type { ModelService } from "../../generated/router/dashboard/v1";
|
|
303
|
+
import { createModelServiceClient } from "../../generated/router/dashboard/v1";
|
|
304
|
+
|
|
305
|
+
// extend ClientFactories + ApiClients
|
|
306
|
+
createModelServiceClient: (handler: RequestHandler) => ModelService;
|
|
307
|
+
modelService: ModelService;
|
|
308
|
+
|
|
309
|
+
// in createApiClients()
|
|
310
|
+
createModelServiceClient,
|
|
311
|
+
|
|
312
|
+
// return object
|
|
313
|
+
modelService: factories.createModelServiceClient(handler),
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Step 4: Run test to verify it passes**
|
|
317
|
+
|
|
318
|
+
Run: `bun run test -- tests/core/api/client.test.ts`
|
|
319
|
+
Expected: PASS.
|
|
320
|
+
|
|
321
|
+
**Step 5: Commit**
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
git add src/core/api/client.ts tests/core/api/client.test.ts
|
|
325
|
+
git commit -m "feat: add model service to api client"
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
### Task 4: Implement models list + fuzzy selection
|
|
331
|
+
|
|
332
|
+
**Files:**
|
|
333
|
+
- Modify: `src/cmd/models.ts`
|
|
334
|
+
- Modify: `src/cmd/index.ts`
|
|
335
|
+
- Test: `tests/cmd/models.test.ts`
|
|
336
|
+
- (If needed) Modify: `tests/cmd/*.test.ts` to include `modelService` in mocked `createApiClients` returns
|
|
337
|
+
|
|
338
|
+
**Step 1: Write the failing tests**
|
|
339
|
+
|
|
340
|
+
Create `tests/cmd/models.test.ts`:
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
import prompts from "prompts";
|
|
344
|
+
import { describe, expect, it, vi } from "vitest";
|
|
345
|
+
import { createProgram } from "../../src/cli";
|
|
346
|
+
import { createApiClients } from "../../src/core/api/client";
|
|
347
|
+
import type { ModelService } from "../../src/generated/router/dashboard/v1";
|
|
348
|
+
import { copyToClipboard } from "../../src/core/interactive/clipboard";
|
|
349
|
+
|
|
350
|
+
vi.mock("../../src/core/api/client", () => ({
|
|
351
|
+
createApiClients: vi.fn(),
|
|
352
|
+
}));
|
|
353
|
+
|
|
354
|
+
vi.mock("../../src/core/interactive/clipboard", () => ({
|
|
355
|
+
copyToClipboard: vi.fn(),
|
|
356
|
+
}));
|
|
357
|
+
|
|
358
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
359
|
+
const setStdinTTY = (value: boolean) => {
|
|
360
|
+
Object.defineProperty(process.stdin, "isTTY", { value, configurable: true });
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const mockModel = {
|
|
364
|
+
id: "gpt-5",
|
|
365
|
+
name: "GPT-5",
|
|
366
|
+
author: "OpenAI",
|
|
367
|
+
enabled: true,
|
|
368
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
afterEach(() => {
|
|
372
|
+
setStdinTTY(originalIsTTY);
|
|
373
|
+
prompts.inject([]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe("models command", () => {
|
|
377
|
+
it("lists models with list subcommand", async () => {
|
|
378
|
+
setStdinTTY(false);
|
|
379
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
380
|
+
modelService: {
|
|
381
|
+
ListModels: vi.fn().mockResolvedValue({ models: [mockModel] }),
|
|
382
|
+
} as unknown as ModelService,
|
|
383
|
+
});
|
|
384
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
385
|
+
const program = createProgram();
|
|
386
|
+
await program.parseAsync(["node", "getrouter", "models", "list"]);
|
|
387
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
388
|
+
expect(output).toContain("ID");
|
|
389
|
+
expect(output).toContain("NAME");
|
|
390
|
+
log.mockRestore();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("uses fuzzy selection when no subcommand", async () => {
|
|
394
|
+
setStdinTTY(true);
|
|
395
|
+
prompts.inject([0]);
|
|
396
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
397
|
+
modelService: {
|
|
398
|
+
ListModels: vi.fn().mockResolvedValue({ models: [mockModel] }),
|
|
399
|
+
} as unknown as ModelService,
|
|
400
|
+
});
|
|
401
|
+
(copyToClipboard as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
|
402
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
403
|
+
const program = createProgram();
|
|
404
|
+
await program.parseAsync(["node", "getrouter", "models"]);
|
|
405
|
+
expect(copyToClipboard).toHaveBeenCalledWith("gpt-5");
|
|
406
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
407
|
+
expect(output).toContain("ID");
|
|
408
|
+
expect(output).toContain("GPT-5");
|
|
409
|
+
log.mockRestore();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**Step 2: Run test to verify it fails**
|
|
415
|
+
|
|
416
|
+
Run: `bun run test -- tests/cmd/models.test.ts`
|
|
417
|
+
Expected: FAIL (command not wired / modelService not used / copy missing).
|
|
418
|
+
|
|
419
|
+
**Step 3: Write minimal implementation**
|
|
420
|
+
|
|
421
|
+
Update `src/cmd/models.ts` to add list + fuzzy behavior:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
import type { Command } from "commander";
|
|
425
|
+
import { createApiClients } from "../core/api/client";
|
|
426
|
+
import { renderTable } from "../core/output/table";
|
|
427
|
+
import { fuzzySelect } from "../core/interactive/fuzzy";
|
|
428
|
+
import { copyToClipboard } from "../core/interactive/clipboard";
|
|
429
|
+
import type { routercommonv1_Model } from "../generated/router/dashboard/v1";
|
|
430
|
+
|
|
431
|
+
const modelHeaders = ["ID", "NAME", "AUTHOR", "ENABLED", "UPDATED_AT"];
|
|
432
|
+
|
|
433
|
+
const modelRow = (model: routercommonv1_Model) => [
|
|
434
|
+
String(model.id ?? ""),
|
|
435
|
+
String(model.name ?? ""),
|
|
436
|
+
String(model.author ?? ""),
|
|
437
|
+
String(model.enabled ?? ""),
|
|
438
|
+
String(model.updatedAt ?? ""),
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
const outputModels = (models: routercommonv1_Model[]) => {
|
|
442
|
+
console.log("🧠 Models");
|
|
443
|
+
console.log(renderTable(modelHeaders, models.map(modelRow)));
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const outputModel = (model: routercommonv1_Model) => {
|
|
447
|
+
console.log(renderTable(modelHeaders, [modelRow(model)]));
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const listModels = async () => {
|
|
451
|
+
const { modelService } = createApiClients({});
|
|
452
|
+
const res = await modelService.ListModels({
|
|
453
|
+
pageSize: undefined,
|
|
454
|
+
pageToken: undefined,
|
|
455
|
+
filter: undefined,
|
|
456
|
+
});
|
|
457
|
+
const models = res?.models ?? [];
|
|
458
|
+
if (models.length === 0) {
|
|
459
|
+
console.log("😕 No models found");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
outputModels(models);
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const fuzzyModels = async () => {
|
|
466
|
+
const { modelService } = createApiClients({});
|
|
467
|
+
const res = await modelService.ListModels({
|
|
468
|
+
pageSize: undefined,
|
|
469
|
+
pageToken: undefined,
|
|
470
|
+
filter: undefined,
|
|
471
|
+
});
|
|
472
|
+
const models = res?.models ?? [];
|
|
473
|
+
if (models.length === 0) {
|
|
474
|
+
console.log("😕 No models found");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const selected = await fuzzySelect({
|
|
478
|
+
message: "🔎 Search models",
|
|
479
|
+
choices: models.map((model) => ({
|
|
480
|
+
title: `${model.name ?? "-"} (${model.id ?? "-"})`,
|
|
481
|
+
value: model,
|
|
482
|
+
keywords: [model.id ?? "", model.author ?? ""].filter(Boolean),
|
|
483
|
+
})),
|
|
484
|
+
});
|
|
485
|
+
if (!selected) return;
|
|
486
|
+
outputModel(selected);
|
|
487
|
+
const copied = await copyToClipboard(selected.id ?? "");
|
|
488
|
+
if (copied) {
|
|
489
|
+
console.log("📋 Copied model id");
|
|
490
|
+
} else if (selected.id) {
|
|
491
|
+
console.log(`Model id: ${selected.id}`);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
export const registerModelsCommands = (program: Command) => {
|
|
496
|
+
const models = program.command("models").description("List models");
|
|
497
|
+
|
|
498
|
+
models.action(async () => {
|
|
499
|
+
if (!process.stdin.isTTY) {
|
|
500
|
+
await listModels();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
await fuzzyModels();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
models
|
|
507
|
+
.command("list")
|
|
508
|
+
.description("List models")
|
|
509
|
+
.action(async () => {
|
|
510
|
+
await listModels();
|
|
511
|
+
});
|
|
512
|
+
};
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Update `src/cmd/index.ts` to register models:
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
import { registerModelsCommands } from "./models";
|
|
519
|
+
// ...
|
|
520
|
+
registerModelsCommands(program);
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
If other tests now require `modelService` in mocked `createApiClients`, add:
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
modelService: {} as unknown as ModelService,
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
**Step 4: Run tests to verify they pass**
|
|
530
|
+
|
|
531
|
+
Run: `bun run test -- tests/cmd/models.test.ts`
|
|
532
|
+
Expected: PASS.
|
|
533
|
+
|
|
534
|
+
**Step 5: Commit**
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
git add src/cmd/models.ts src/cmd/index.ts tests/cmd/models.test.ts tests/cmd/*.test.ts
|
|
538
|
+
git commit -m "feat: add models list and fuzzy selection"
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
### Task 5: Implement keys fuzzy selection + copy API key
|
|
544
|
+
|
|
545
|
+
**Files:**
|
|
546
|
+
- Modify: `src/cmd/keys.ts`
|
|
547
|
+
- Modify: `src/core/interactive/keys.ts`
|
|
548
|
+
- Test: `tests/cmd/keys.test.ts`
|
|
549
|
+
|
|
550
|
+
**Step 1: Write the failing tests**
|
|
551
|
+
|
|
552
|
+
Update `tests/cmd/keys.test.ts`:
|
|
553
|
+
|
|
554
|
+
- Remove the “requires a subcommand” expectation.
|
|
555
|
+
- Add a new test for fuzzy selection on `keys` (no subcommand) with clipboard copy:
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
import { copyToClipboard } from "../../src/core/interactive/clipboard";
|
|
559
|
+
|
|
560
|
+
vi.mock("../../src/core/interactive/clipboard", () => ({
|
|
561
|
+
copyToClipboard: vi.fn(),
|
|
562
|
+
}));
|
|
563
|
+
|
|
564
|
+
it("uses fuzzy selection when no subcommand", async () => {
|
|
565
|
+
setStdinTTY(true);
|
|
566
|
+
prompts.inject([0]);
|
|
567
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
568
|
+
consumerService: {
|
|
569
|
+
ListConsumers: vi.fn().mockResolvedValue({ consumers: [mockConsumer] }),
|
|
570
|
+
} as unknown as ConsumerService,
|
|
571
|
+
subscriptionService: emptySubscriptionService,
|
|
572
|
+
authService: emptyAuthService,
|
|
573
|
+
});
|
|
574
|
+
(copyToClipboard as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
|
575
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
576
|
+
const program = createProgram();
|
|
577
|
+
await program.parseAsync(["node", "getrouter", "keys"]);
|
|
578
|
+
expect(copyToClipboard).toHaveBeenCalledWith("abcd1234WXYZ");
|
|
579
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
580
|
+
expect(output).toContain("ID");
|
|
581
|
+
log.mockRestore();
|
|
582
|
+
});
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
**Step 2: Run test to verify it fails**
|
|
586
|
+
|
|
587
|
+
Run: `bun run test -- tests/cmd/keys.test.ts`
|
|
588
|
+
Expected: FAIL (no fuzzy behavior / clipboard not called).
|
|
589
|
+
|
|
590
|
+
**Step 3: Write minimal implementation**
|
|
591
|
+
|
|
592
|
+
Update `src/core/interactive/keys.ts` to use fuzzy selection for consumers:
|
|
593
|
+
|
|
594
|
+
```ts
|
|
595
|
+
import { fuzzySelect } from "./fuzzy";
|
|
596
|
+
|
|
597
|
+
export const selectConsumer = async (
|
|
598
|
+
consumerService: ConsumerService,
|
|
599
|
+
): Promise<routercommonv1_Consumer | null> => {
|
|
600
|
+
const res = await consumerService.ListConsumers({
|
|
601
|
+
pageSize: undefined,
|
|
602
|
+
pageToken: undefined,
|
|
603
|
+
});
|
|
604
|
+
const consumers = res?.consumers ?? [];
|
|
605
|
+
if (consumers.length === 0) {
|
|
606
|
+
throw new Error("No available API keys");
|
|
607
|
+
}
|
|
608
|
+
const sorted = sortByCreatedAtDesc(consumers);
|
|
609
|
+
const selected = await fuzzySelect({
|
|
610
|
+
message: "🔎 Search keys",
|
|
611
|
+
choices: sorted.map((consumer) => ({
|
|
612
|
+
title: formatChoice(consumer),
|
|
613
|
+
value: consumer,
|
|
614
|
+
keywords: [consumer.id ?? "", consumer.name ?? ""].filter(Boolean),
|
|
615
|
+
})),
|
|
616
|
+
});
|
|
617
|
+
return selected ?? null;
|
|
618
|
+
};
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
Update `src/cmd/keys.ts` to add fuzzy behavior on base `keys` command and copy API key:
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
import { copyToClipboard } from "../core/interactive/clipboard";
|
|
625
|
+
|
|
626
|
+
const fuzzyKeys = async () => {
|
|
627
|
+
const { consumerService } = createApiClients({});
|
|
628
|
+
const selected = await selectConsumer(consumerService);
|
|
629
|
+
if (!selected) return;
|
|
630
|
+
outputConsumerTable(redactConsumer(selected));
|
|
631
|
+
const apiKey = selected.apiKey ?? "";
|
|
632
|
+
const copied = await copyToClipboard(apiKey);
|
|
633
|
+
if (copied) {
|
|
634
|
+
console.log("📋 Copied API key");
|
|
635
|
+
} else if (apiKey) {
|
|
636
|
+
console.log(`API key: ${apiKey}`);
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// In registerKeysCommands
|
|
641
|
+
keys.action(async () => {
|
|
642
|
+
if (!process.stdin.isTTY) {
|
|
643
|
+
console.error(
|
|
644
|
+
"Use subcommand: list|get|create|update|delete (e.g. `getrouter keys list`).",
|
|
645
|
+
);
|
|
646
|
+
process.exitCode = 1;
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
await fuzzyKeys();
|
|
650
|
+
});
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
**Step 4: Run tests to verify they pass**
|
|
654
|
+
|
|
655
|
+
Run: `bun run test -- tests/cmd/keys.test.ts`
|
|
656
|
+
Expected: PASS.
|
|
657
|
+
|
|
658
|
+
**Step 5: Commit**
|
|
659
|
+
|
|
660
|
+
```bash
|
|
661
|
+
git add src/core/interactive/keys.ts src/cmd/keys.ts tests/cmd/keys.test.ts
|
|
662
|
+
git commit -m "feat: add keys fuzzy selection"
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
---
|
|
666
|
+
|
|
667
|
+
### Task 6: Full verification
|
|
668
|
+
|
|
669
|
+
**Files:**
|
|
670
|
+
- N/A
|
|
671
|
+
|
|
672
|
+
**Step 1: Run full test suite**
|
|
673
|
+
|
|
674
|
+
Run: `bun run test`
|
|
675
|
+
Expected: PASS.
|
|
676
|
+
|
|
677
|
+
**Step 2: Run typecheck + lint + format**
|
|
678
|
+
|
|
679
|
+
Run: `bun run typecheck`
|
|
680
|
+
Expected: PASS.
|
|
681
|
+
|
|
682
|
+
Run: `bun run lint`
|
|
683
|
+
Expected: PASS.
|
|
684
|
+
|
|
685
|
+
Run: `bun run format`
|
|
686
|
+
Expected: PASS (no changes).
|
|
687
|
+
|
|
688
|
+
**Step 3: Commit if formatting changes**
|
|
689
|
+
|
|
690
|
+
```bash
|
|
691
|
+
git add -A
|
|
692
|
+
git commit -m "chore: format"
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
## Execution Handoff
|
|
698
|
+
|
|
699
|
+
Plan complete and saved to `docs/plans/2026-01-04-models-keys-fuzzy-implementation.md`. Two execution options:
|
|
700
|
+
|
|
701
|
+
1. Subagent-Driven (this session) – I dispatch fresh subagent per task, review between tasks
|
|
702
|
+
2. Parallel Session (separate) – Open new session with executing-plans, batch execution with checkpoints
|
|
703
|
+
|
|
704
|
+
Which approach?
|