honeydo 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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/README.zh-CN.md +88 -0
  4. package/package.json +54 -0
  5. package/packages/cli/dist/index.d.ts +2 -0
  6. package/packages/cli/dist/index.js +171 -0
  7. package/packages/cli/dist/index.js.map +1 -0
  8. package/packages/doubao/dist/cli.d.ts +38 -0
  9. package/packages/doubao/dist/cli.d.ts.map +1 -0
  10. package/packages/doubao/dist/cli.js +206 -0
  11. package/packages/gcli/dist/cli.d.ts +465 -0
  12. package/packages/gcli/dist/cli.js +2017 -0
  13. package/packages/gcli/dist/cli.js.map +1 -0
  14. package/packages/lmedia/dist/index.d.ts +1 -0
  15. package/packages/lmedia/dist/index.js +1594 -0
  16. package/packages/lmedia/python/edit.py +107 -0
  17. package/packages/lmedia/python/esrgan_path.py +16 -0
  18. package/packages/lmedia/python/gen.py +131 -0
  19. package/packages/lmedia/python/serve.py +352 -0
  20. package/packages/lmedia/python/sfx.py +527 -0
  21. package/packages/lmedia/python/teacache.py +255 -0
  22. package/packages/lmedia/python/upscale.py +41 -0
  23. package/packages/minimax/dist/cli.d.ts +51 -0
  24. package/packages/minimax/dist/cli.js +307 -0
  25. package/packages/minimax/dist/cli.js.map +1 -0
  26. package/packages/minimax/dist/client.d.ts +20 -0
  27. package/packages/minimax/dist/client.js +55 -0
  28. package/packages/minimax/dist/client.js.map +1 -0
  29. package/packages/minimax/dist/tts.d.ts +33 -0
  30. package/packages/minimax/dist/tts.js +64 -0
  31. package/packages/minimax/dist/tts.js.map +1 -0
  32. package/packages/minimax/dist/validate.d.ts +29 -0
  33. package/packages/minimax/dist/validate.js +122 -0
  34. package/packages/minimax/dist/validate.js.map +1 -0
  35. package/packages/minimax/dist/voice-clone.d.ts +17 -0
  36. package/packages/minimax/dist/voice-clone.js +47 -0
  37. package/packages/minimax/dist/voice-clone.js.map +1 -0
  38. package/packages/minimax/dist/voices.d.ts +17 -0
  39. package/packages/minimax/dist/voices.js +20 -0
  40. package/packages/minimax/dist/voices.js.map +1 -0
  41. package/packages/qwen/dist/index.d.ts +1 -0
  42. package/packages/qwen/dist/index.js +311 -0
@@ -0,0 +1,2017 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gcli — thin wrapper around the `agy` and `claude` CLI backends.
4
+ *
5
+ * Default backend is claude: bare `gcli ...` is exactly `gcli claude ...`.
6
+ * `gcli agy ...` routes to the agy CLI (explicit subcommand required); the
7
+ * claude backend can switch cc-switch providers inline via
8
+ * `claude -p ... --settings`. Adds value over calling the backends directly:
9
+ * prompt via argv or stdin (`-p -`), 50k-char output truncation, a hard
10
+ * timeout (spawn SIGTERM), explicit exit codes, and empty-output detection.
11
+ *
12
+ * The claude backend resolves `--provider <name>` from the cc-switch SQLite
13
+ * DB (read-only) and never rewrites ~/.claude/settings.json — the provider
14
+ * switch happens entirely through claude's own `--settings` merge. In a TTY
15
+ * without --provider it offers an interactive arrow-key picker over the
16
+ * cc-switch provider list (↑↓/j/k move · Enter confirm · Esc keep default);
17
+ * the last confirmed provider is remembered in ~/.config/gcli/last-provider
18
+ * and reused silently in print mode. Without a TTY the picker never triggers
19
+ * (zero prompts, zero DB reads, zero memory-file IO) so skills/CI never hang.
20
+ *
21
+ * Note: on the default (claude) path --yolo/--sandbox are rejected (exit 2);
22
+ * agy users must opt in via the explicit `gcli agy` subcommand.
23
+ *
24
+ * Timeout is enforced by spawn SIGTERM (deterministic), not via either
25
+ * backend's own timeout flag.
26
+ */
27
+ import { spawn } from "node:child_process";
28
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { dirname, resolve } from "node:path";
31
+ import { emitKeypressEvents } from "node:readline";
32
+ import { fileURLToPath } from "node:url";
33
+ import { parseArgs } from "node:util";
34
+ export const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes
35
+ export const CHARACTER_LIMIT = 50_000;
36
+ export const API_DEFAULT_MAX_TOKENS = 80000;
37
+ const AGY_BIN = "agy";
38
+ const CLAUDE_BIN = "claude";
39
+ const VERSION_TIMEOUT_MS = 10_000;
40
+ /**
41
+ * Path to the cc-switch SQLite database. cc-switch ships provider configs
42
+ * (including ANTHROPIC_* env) here; gcli reads it read-only to resolve
43
+ * `--provider <name>` for the claude backend.
44
+ */
45
+ export const CC_SWITCH_DB_PATH = `${homedir()}/.cc-switch/cc-switch.db`;
46
+ /**
47
+ * Memory file for the last picker-confirmed provider (D2). Content is a
48
+ * single UTF-8 line with the provider name. Read on the TTY claude path with
49
+ * no --provider (exact-name match against the current list; mismatch/missing/
50
+ * unreadable → silently ignored); written best-effort after a picker confirm.
51
+ * Never read or written on a non-TTY path (zero side effects for skills/CI).
52
+ */
53
+ export const LAST_PROVIDER_PATH = `${homedir()}/.config/gcli/last-provider`;
54
+ /**
55
+ * Quota subtitle cache (revise-3, C-Q5): `{[name]: {ts, ok, text?}}`.
56
+ * TTL ok 60s / fail 15s (statusline-sage semantics); reads/writes are
57
+ * best-effort — the quota subtitle is an optimization, never an error.
58
+ */
59
+ export const QUOTA_CACHE_PATH = `${homedir()}/.config/gcli/quota-cache.json`;
60
+ const QUOTA_TTL_OK_MS = 60_000;
61
+ const QUOTA_TTL_FAIL_MS = 15_000;
62
+ const QUOTA_FETCH_TIMEOUT_MS = 2_500;
63
+ /** Provider-name allowlist (C4b). Names outside this set are rejected. */
64
+ const PROVIDER_NAME_RE = /^[A-Za-z0-9 &._-]+$/;
65
+ // ---------------------------------------------------------------------------
66
+ // Pure helpers (unit-tested)
67
+ // ---------------------------------------------------------------------------
68
+ /**
69
+ * Detect a leading `agy`/`claude`/`api` subcommand and strip it. Strict (C1):
70
+ * - argv[0] === "agy" → subcommand "agy", rest = argv.slice(1)
71
+ * - argv[0] === "claude" → subcommand "claude", rest = argv.slice(1)
72
+ * - argv[0] === "api" → subcommand "api", rest = argv.slice(1)
73
+ * - argv empty OR argv[0] starts with "-" → subcommand undefined (default
74
+ * backend: claude)
75
+ * - argv[0] any other non-empty token → error "unknown subcommand: <x>"
76
+ *
77
+ * This keeps `gcli -p claude` (argv[0]="-p") on the default claude path with
78
+ * prompt="claude" — the subcommand must literally lead.
79
+ */
80
+ export function parseSubcommand(argv) {
81
+ if (argv.length === 0)
82
+ return { subcommand: undefined, rest: argv };
83
+ const first = argv[0];
84
+ if (first === "agy")
85
+ return { subcommand: "agy", rest: argv.slice(1) };
86
+ if (first === "claude")
87
+ return { subcommand: "claude", rest: argv.slice(1) };
88
+ if (first === "api")
89
+ return { subcommand: "api", rest: argv.slice(1) };
90
+ if (first.startsWith("-"))
91
+ return { subcommand: undefined, rest: argv };
92
+ return { error: `unknown subcommand: ${first}` };
93
+ }
94
+ export function truncate(text) {
95
+ if (text.length <= CHARACTER_LIMIT)
96
+ return text;
97
+ return (text.slice(0, CHARACTER_LIMIT) +
98
+ `\n\n[Truncated — response exceeded ${CHARACTER_LIMIT} characters]`);
99
+ }
100
+ /** Translate gcli options into agy argv. Timeout is enforced by spawn kill.
101
+ * prompt is optional — when undefined, no -p is emitted (interactive mode). */
102
+ export function buildAgyArgs(opts) {
103
+ const args = [];
104
+ if (opts.model)
105
+ args.push("--model", opts.model);
106
+ if (opts.yolo)
107
+ args.push("--dangerously-skip-permissions");
108
+ if (opts.sandbox)
109
+ args.push("--sandbox");
110
+ if (opts.cwd)
111
+ args.push("--add-dir", resolve(opts.cwd));
112
+ if (opts.prompt !== undefined)
113
+ args.push("-p", opts.prompt);
114
+ if (opts.passthrough?.length)
115
+ args.push(...opts.passthrough);
116
+ return args;
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // Claude backend pure helpers
120
+ // ---------------------------------------------------------------------------
121
+ /**
122
+ * Three-layer provider name matching (C4a):
123
+ * 1. exact equality
124
+ * 2. case-insensitive equality
125
+ * 3. substring (query within name, case-insensitive)
126
+ * Within the first layer that produces any hit: 1 → {matched}, >1 →
127
+ * {ambiguous}, 0 → fall through. No layer hits → {none}.
128
+ */
129
+ export function matchProviderName(query, names) {
130
+ const layers = [
131
+ names.filter((n) => n === query),
132
+ names.filter((n) => n.toLowerCase() === query.toLowerCase()),
133
+ names.filter((n) => n.toLowerCase().includes(query.toLowerCase())),
134
+ ];
135
+ for (const layer of layers) {
136
+ if (layer.length === 0)
137
+ continue;
138
+ if (layer.length === 1)
139
+ return { matched: layer[0] };
140
+ return { ambiguous: layer };
141
+ }
142
+ return { none: true };
143
+ }
144
+ /**
145
+ * Parse a `settings_config` JSON string and return its `env` block (C4c).
146
+ * Errors (with diagnostics) on malformed JSON, non-object root, or a
147
+ * missing/non-object env.
148
+ */
149
+ export function extractProviderEnv(settingsConfigJson) {
150
+ let cfg;
151
+ try {
152
+ cfg = JSON.parse(settingsConfigJson);
153
+ }
154
+ catch {
155
+ return { error: "malformed settings_config JSON" };
156
+ }
157
+ if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg)) {
158
+ return { error: "settings_config is not a JSON object" };
159
+ }
160
+ const env = cfg.env;
161
+ if (env === undefined ||
162
+ env === null ||
163
+ typeof env !== "object" ||
164
+ Array.isArray(env)) {
165
+ return { error: "settings_config has no env block" };
166
+ }
167
+ return { env: env };
168
+ }
169
+ /**
170
+ * Copy provider env and explicitly pin `ANTHROPIC_MODEL` (C5).
171
+ *
172
+ * Why: `claude --settings` is a *merge*, not replace — a stale
173
+ * ANTHROPIC_MODEL in the global settings.json would leak through. We always
174
+ * set the key (empty string when nothing is derivable — JSON.stringify omits
175
+ * undefined, which would let a stale global value leak back in).
176
+ *
177
+ * Priority: model > provider ANTHROPIC_MODEL > DEFAULT_SONNET_MODEL
178
+ * > DEFAULT_OPUS_MODEL > first sorted DEFAULT_*_MODEL.
179
+ */
180
+ export function buildSettingsEnv(providerEnv, model) {
181
+ const env = { ...providerEnv };
182
+ const firstDefault = Object.keys(env)
183
+ .filter((k) => k.startsWith("ANTHROPIC_DEFAULT_") && k.endsWith("_MODEL"))
184
+ .sort()[0];
185
+ const resolved = model ??
186
+ env.ANTHROPIC_MODEL ??
187
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL ??
188
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL ??
189
+ (firstDefault !== undefined ? env[firstDefault] : undefined);
190
+ env.ANTHROPIC_MODEL = (resolved ?? "");
191
+ return env;
192
+ }
193
+ /**
194
+ * Map one arrow-key picker keypress to its state transition (C-P2, revise-2).
195
+ *
196
+ * `k` is the keypress event's `key` object:
197
+ * - "up" / "k" → move up one row, wrapping past the top (环形)
198
+ * - "down" / "j" → move down one row, wrapping past the bottom (环形)
199
+ * - "return" / "enter" → confirm the current row (the physical Enter key
200
+ * emits "return" in raw mode; "enter" is the LF byte, which a tty may
201
+ * substitute for CR in input buffered before raw mode was enabled)
202
+ * - "escape" → skip (不切换)
203
+ * - Emacs (revise-2): ctrl+"n" ≡ down, ctrl+"p" ≡ up (same wrap); ctrl+"g"
204
+ * ≡ escape → skip; meta+"<" → first row (absolute), meta+">" → last row
205
+ * (absolute). Horizontal Emacs keys (C-f/C-b/C-a/C-e) and paging (C-v/M-v)
206
+ * are deliberately NOT mapped — meaningless in a vertical menu.
207
+ * - anything else → noop (ctrl-c is handled by the caller: restore raw-mode,
208
+ * exit 130)
209
+ *
210
+ * `index` is the highlighted row, `count` the total rendered rows INCLUDING
211
+ * the trailing 不切换 row. Movement wraps with `(index±1+count)%count`; with
212
+ * count <= 0 moves are a noop (nothing is rendered).
213
+ */
214
+ export function applyPickerKey(k, index, count) {
215
+ const name = k.name;
216
+ if (name === undefined)
217
+ return { type: "noop" };
218
+ if (k.ctrl === true) {
219
+ // Emacs cluster (revise-2): C-n/C-p move, C-g skips; other C-x noop.
220
+ if (name === "n") {
221
+ if (count <= 0)
222
+ return { type: "noop" };
223
+ return { type: "move", index: (index + 1) % count };
224
+ }
225
+ if (name === "p") {
226
+ if (count <= 0)
227
+ return { type: "noop" };
228
+ return { type: "move", index: (index - 1 + count) % count };
229
+ }
230
+ if (name === "g")
231
+ return { type: "skip" };
232
+ return { type: "noop" };
233
+ }
234
+ if (k.meta === true) {
235
+ // M-< / M-> jump to the first/last row (absolute, no wrap).
236
+ if (name === "<" && count > 0)
237
+ return { type: "move", index: 0 };
238
+ if (name === ">" && count > 0)
239
+ return { type: "move", index: count - 1 };
240
+ return { type: "noop" };
241
+ }
242
+ if (name === "up" || name === "k") {
243
+ if (count <= 0)
244
+ return { type: "noop" };
245
+ return { type: "move", index: (index - 1 + count) % count };
246
+ }
247
+ if (name === "down" || name === "j") {
248
+ if (count <= 0)
249
+ return { type: "noop" };
250
+ return { type: "move", index: (index + 1) % count };
251
+ }
252
+ if (name === "return" || name === "enter")
253
+ return { type: "confirm" };
254
+ if (name === "escape")
255
+ return { type: "skip" };
256
+ return { type: "noop" };
257
+ }
258
+ /**
259
+ * Map a provider env to its quota API request (C-Q1):
260
+ * - base contains kimi.com / moonshot → kimi: `{domain}/coding/v1/usages`,
261
+ * `Authorization: Bearer <token>` (bare token gets 401)
262
+ * - base contains bigmodel / z.ai → glm: `{domain}/api/monitor/usage/quota/limit`,
263
+ * `Authorization: <token>` (NO Bearer prefix)
264
+ * - anything else (deepseek / packy / anthropic official / …) or missing
265
+ * base/token/scheme → null: no quota API, zero requests, no subtitle.
266
+ */
267
+ export function buildQuotaRequest(env) {
268
+ const base = env.ANTHROPIC_BASE_URL;
269
+ const token = env.ANTHROPIC_AUTH_TOKEN;
270
+ if (typeof base !== "string" || base === "")
271
+ return null;
272
+ if (typeof token !== "string" || token === "")
273
+ return null;
274
+ const m = /^https?:\/\/[^/]+/.exec(base);
275
+ if (m === null)
276
+ return null;
277
+ const domain = m[0];
278
+ if (base.includes("kimi.com") || base.includes("moonshot")) {
279
+ return {
280
+ kind: "kimi",
281
+ url: `${domain}/coding/v1/usages`,
282
+ authHeader: `Bearer ${token}`,
283
+ };
284
+ }
285
+ if (base.includes("bigmodel") || base.includes("z.ai")) {
286
+ return {
287
+ kind: "glm",
288
+ url: `${domain}/api/monitor/usage/quota/limit`,
289
+ authHeader: token,
290
+ };
291
+ }
292
+ return null;
293
+ }
294
+ /** Coerce a kimi/glm numeric field (they arrive as JSON strings) safely. */
295
+ function toNum(v) {
296
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
297
+ return Number.isFinite(n) ? n : undefined;
298
+ }
299
+ /**
300
+ * Normalize a reset timestamp to an ISO string (runtime-verified: kimi's
301
+ * resetTime is ISO8601, but GLM's nextResetTime is an epoch-ms NUMBER —
302
+ * statusline-sage never parses dates so this was only discoverable live).
303
+ * Accepts string (ISO) or finite positive number (epoch ms); else undefined.
304
+ */
305
+ function toResetIso(v) {
306
+ if (typeof v === "string" && v !== "")
307
+ return v;
308
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) {
309
+ const iso = new Date(v).toISOString();
310
+ return iso;
311
+ }
312
+ return undefined;
313
+ }
314
+ /**
315
+ * One kimi-style usage window (C-Q2): used/limit are strings; used may be
316
+ * absent → limit − remaining; any malformed piece drops the whole window
317
+ * (never throws). pct = floor(used/limit*100).
318
+ */
319
+ function kimiWindowOf(detail) {
320
+ if (typeof detail !== "object" || detail === null)
321
+ return undefined;
322
+ const d = detail;
323
+ const limit = toNum(d.limit);
324
+ let used = toNum(d.used);
325
+ if (used === undefined) {
326
+ const remaining = toNum(d.remaining);
327
+ if (limit !== undefined && remaining !== undefined)
328
+ used = limit - remaining;
329
+ }
330
+ if (limit === undefined || used === undefined || limit <= 0)
331
+ return undefined;
332
+ const resetIso = toResetIso(d.resetTime);
333
+ if (resetIso === undefined)
334
+ return undefined;
335
+ return { pct: Math.floor((used / limit) * 100), resetIso };
336
+ }
337
+ /**
338
+ * Parse kimi `/coding/v1/usages` (C-Q2): `limits[]` entries with
339
+ * window.duration == 300 + MINUTE → short (5h) window; top-level `usage` →
340
+ * weekly. Malformed shapes yield missing windows, never throw.
341
+ */
342
+ export function parseKimiUsages(body) {
343
+ const out = {};
344
+ if (typeof body !== "object" || body === null)
345
+ return out;
346
+ const b = body;
347
+ const limits = Array.isArray(b.limits) ? b.limits : [];
348
+ for (const item of limits) {
349
+ if (typeof item !== "object" || item === null)
350
+ continue;
351
+ const rec = item;
352
+ if (typeof rec.window !== "object" || rec.window === null)
353
+ continue;
354
+ const w = rec.window;
355
+ if (toNum(w.duration) !== 300)
356
+ continue;
357
+ const unit = typeof w.timeUnit === "string" ? w.timeUnit : "";
358
+ if (!unit.includes("MINUTE"))
359
+ continue;
360
+ const win = kimiWindowOf(rec.detail);
361
+ if (win !== undefined)
362
+ out.short = win;
363
+ }
364
+ const weekly = kimiWindowOf(b.usage);
365
+ if (weekly !== undefined)
366
+ out.weekly = weekly;
367
+ return out;
368
+ }
369
+ /**
370
+ * Parse GLM `/api/monitor/usage/quota/limit` (C-Q2): data.limits[] entries
371
+ * with type == "TOKENS_LIMIT"; sorted by nextResetTime ascending — first is
372
+ * the short (5h) window, last the weekly one.
373
+ */
374
+ export function parseGlmQuota(body) {
375
+ const out = {};
376
+ if (typeof body !== "object" || body === null)
377
+ return out;
378
+ const data = body.data;
379
+ if (typeof data !== "object" || data === null)
380
+ return out;
381
+ const limitsRaw = data.limits;
382
+ const limits = Array.isArray(limitsRaw) ? limitsRaw : [];
383
+ const wins = [];
384
+ for (const item of limits) {
385
+ if (typeof item !== "object" || item === null)
386
+ continue;
387
+ const r = item;
388
+ if (r.type !== "TOKENS_LIMIT")
389
+ continue;
390
+ const pct = toNum(r.percentage);
391
+ if (pct === undefined)
392
+ continue;
393
+ const resetIso = toResetIso(r.nextResetTime);
394
+ if (resetIso === undefined)
395
+ continue;
396
+ wins.push({ pct: Math.floor(pct), resetIso });
397
+ }
398
+ wins.sort((a, b) => a.resetIso < b.resetIso ? -1 : a.resetIso > b.resetIso ? 1 : 0);
399
+ if (wins.length > 0)
400
+ out.short = wins[0];
401
+ if (wins.length > 1)
402
+ out.weekly = wins[wins.length - 1];
403
+ return out;
404
+ }
405
+ /** Render a reset timestamp as a short relative duration (C-Q3). */
406
+ function formatReset(iso, nowMs) {
407
+ if (iso === undefined)
408
+ return undefined;
409
+ const t = Date.parse(iso);
410
+ if (!Number.isFinite(t))
411
+ return undefined;
412
+ const min = Math.floor((t - nowMs) / 60_000);
413
+ if (min < 1)
414
+ return undefined; // already reset / about to — omit
415
+ if (min < 60)
416
+ return `${min}m`;
417
+ const h = Math.floor(min / 60);
418
+ const rm = min % 60;
419
+ if (h < 24)
420
+ return rm > 0 ? `${h}h${rm}m` : `${h}h`;
421
+ const d = Math.floor(h / 24);
422
+ const rh = h % 24;
423
+ return rh > 0 ? `${d}d${rh}h` : `${d}d`;
424
+ }
425
+ /**
426
+ * Format quota windows as the menu subtitle (C-Q3): `5h:P% wk:P% ↻<rel>`
427
+ * (short reset preferred for the ↻ segment); missing windows degrade; a
428
+ * missing/expired reset omits the ↻ segment entirely; no windows → "".
429
+ */
430
+ export function formatQuota(q, nowMs) {
431
+ const parts = [];
432
+ if (q.short !== undefined)
433
+ parts.push(`5h:${q.short.pct}%`);
434
+ if (q.weekly !== undefined)
435
+ parts.push(`wk:${q.weekly.pct}%`);
436
+ if (parts.length === 0)
437
+ return "";
438
+ const rel = formatReset(q.short?.resetIso ?? q.weekly?.resetIso, nowMs);
439
+ return rel === undefined ? parts.join(" ") : `${parts.join(" ")} ↻${rel}`;
440
+ }
441
+ /**
442
+ * Build claude argv (C8). `--model` is NOT passed here — for the claude
443
+ * backend it goes into the settings env's ANTHROPIC_MODEL via buildSettingsEnv.
444
+ * `--cwd` becomes `claude --add-dir` to match the agy convention.
445
+ * prompt is optional — when undefined, no -p is emitted (interactive mode).
446
+ */
447
+ export function buildClaudeArgs(opts) {
448
+ const args = [];
449
+ if (opts.prompt !== undefined)
450
+ args.push("-p", opts.prompt);
451
+ if (opts.settingsEnv) {
452
+ args.push("--settings", JSON.stringify({ env: opts.settingsEnv }));
453
+ }
454
+ if (opts.cwd) {
455
+ args.push("--add-dir", resolve(opts.cwd));
456
+ }
457
+ if (opts.passthrough?.length)
458
+ args.push(...opts.passthrough);
459
+ return args;
460
+ }
461
+ /**
462
+ * Build the HTTP body for an anthropic-compatible /v1/messages request.
463
+ *
464
+ * thinking is intentionally NOT disabled: k3's extended thinking is the quality
465
+ * source for creative+SVG tasks (dry-run: single-block ~768-char thinking yields
466
+ * high-quality output in ~45s vs claude-agent's 53min). We rely on a sufficient
467
+ * --max-tokens budget (default 80000) to cover both thinking and text, not on
468
+ * disabling thinking. Disabling it would discard the very capability we chose
469
+ * k3 for.
470
+ */
471
+ export function buildApiBody(req) {
472
+ return {
473
+ model: req.model,
474
+ max_tokens: req.maxTokens,
475
+ stream: req.stream,
476
+ messages: [{ role: "user", content: req.prompt }],
477
+ };
478
+ }
479
+ /**
480
+ * Build the full URL for the messages endpoint. The cc-switch base_url is
481
+ * stored with a trailing slash (e.g. `https://api.kimi.com/coding/`); we
482
+ * append `v1/messages` without doubling the slash.
483
+ */
484
+ export function buildApiEndpoint(baseUrl) {
485
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
486
+ return `${base}v1/messages`;
487
+ }
488
+ /**
489
+ * SSE line → extracted text delta, or null if the line carries no text payload.
490
+ *
491
+ * Handles anthropic-compatible streaming events:
492
+ * - `data:{...}` JSON with `delta.type === "text_delta"` → the text fragment
493
+ * - `thinking_delta` / `signature_delta` / control events → null (ignored)
494
+ * - non-`data:` lines / malformed JSON → null
495
+ *
496
+ * The `data:` prefix is matched greedily and the remainder is trimmed, so both
497
+ * `data:{...}` (kimi, no space) and `data: {...}` (with space) parse the same.
498
+ */
499
+ export function extractTextDelta(line) {
500
+ const trimmed = line.trim();
501
+ if (!trimmed.startsWith("data:"))
502
+ return null;
503
+ const payload = trimmed.slice("data:".length).trim();
504
+ if (!payload || payload === "[DONE]")
505
+ return null;
506
+ try {
507
+ const evt = JSON.parse(payload);
508
+ if (evt.type === "content_block_delta" &&
509
+ evt.delta?.type === "text_delta" &&
510
+ typeof evt.delta.text === "string") {
511
+ return evt.delta.text;
512
+ }
513
+ return null;
514
+ }
515
+ catch {
516
+ return null;
517
+ }
518
+ }
519
+ /**
520
+ * Aggregate text from a non-streaming messages response body.
521
+ *
522
+ * Non-stream responses look like `{ content: [{ type: "text", text: "..." }] }`;
523
+ * we concatenate every text block in order.
524
+ */
525
+ export function extractNonStreamText(body) {
526
+ if (typeof body !== "object" || body === null)
527
+ return "";
528
+ const content = body.content;
529
+ if (!Array.isArray(content))
530
+ return "";
531
+ let out = "";
532
+ for (const block of content) {
533
+ if (typeof block === "object" &&
534
+ block !== null &&
535
+ block.type === "text" &&
536
+ typeof block.text === "string") {
537
+ out += block.text;
538
+ }
539
+ }
540
+ return out;
541
+ }
542
+ function isOk(r) {
543
+ return !("error" in r);
544
+ }
545
+ /** gcli's own option names; anything else is forwarded to the backend. */
546
+ const KNOWN_OPTION_NAMES = new Set([
547
+ "prompt",
548
+ "p",
549
+ "model",
550
+ "yolo",
551
+ "sandbox",
552
+ "cwd",
553
+ "timeout",
554
+ "version",
555
+ "help",
556
+ "provider",
557
+ "pick",
558
+ ]);
559
+ export function parseCliArgs(argv) {
560
+ // `--` explicitly forwards everything after it. Unknown flags and bare
561
+ // positionals before `--` are also auto-forwarded, so callers don't need to
562
+ // remember `--` — only gcli's own flags above are consumed.
563
+ const ddIdx = argv.indexOf("--");
564
+ const before = ddIdx >= 0 ? argv.slice(0, ddIdx) : argv;
565
+ const afterDd = ddIdx >= 0 ? argv.slice(ddIdx + 1) : [];
566
+ try {
567
+ const { values, tokens } = parseArgs({
568
+ args: before,
569
+ options: {
570
+ prompt: { short: "p", type: "string" },
571
+ model: { type: "string" },
572
+ yolo: { type: "boolean" },
573
+ sandbox: { type: "boolean" },
574
+ cwd: { type: "string" },
575
+ timeout: { type: "string" },
576
+ version: { type: "boolean" },
577
+ help: { type: "boolean" },
578
+ provider: { type: "string" },
579
+ pick: { type: "boolean" },
580
+ },
581
+ strict: false,
582
+ tokens: true,
583
+ allowNegative: true,
584
+ });
585
+ // Auto-forward unknown options and bare positionals to the backend.
586
+ const passthrough = [];
587
+ for (const t of tokens) {
588
+ if (t.kind === "positional") {
589
+ passthrough.push(t.value);
590
+ }
591
+ else if (t.kind === "option" && !KNOWN_OPTION_NAMES.has(t.name)) {
592
+ if (t.inlineValue && t.value !== undefined) {
593
+ passthrough.push(`${t.rawName}=${t.value}`);
594
+ }
595
+ else {
596
+ passthrough.push(t.rawName);
597
+ }
598
+ }
599
+ }
600
+ passthrough.push(...afterDd);
601
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
602
+ if (values.timeout !== undefined) {
603
+ const n = Number(values.timeout);
604
+ if (!Number.isFinite(n) || n < 1000 || n > 1_800_000) {
605
+ return {
606
+ error: `--timeout must be a number in [1000, 1800000], got "${values.timeout}"`,
607
+ };
608
+ }
609
+ timeoutMs = n;
610
+ }
611
+ return {
612
+ prompt: typeof values.prompt === "string" ? values.prompt : undefined,
613
+ model: typeof values.model === "string" ? values.model : undefined,
614
+ yolo: values.yolo === true,
615
+ sandbox: values.sandbox === true,
616
+ cwd: typeof values.cwd === "string" ? values.cwd : undefined,
617
+ timeoutMs,
618
+ version: values.version === true,
619
+ help: values.help === true,
620
+ provider: typeof values.provider === "string" ? values.provider : undefined,
621
+ pick: values.pick === true,
622
+ passthrough,
623
+ };
624
+ }
625
+ catch (err) {
626
+ return { error: err instanceof Error ? err.message : String(err) };
627
+ }
628
+ }
629
+ /**
630
+ * Parse argv for the api backend. Strict (contract: api does NOT passthrough):
631
+ * unknown flags and bare positionals are errors (`unknown option/positional`),
632
+ * mapped to exit 2 by the caller. `--no-stream` is accepted via
633
+ * `allowNegative` and flips `stream` to false.
634
+ */
635
+ export function parseApiArgs(argv) {
636
+ // Agent-only flags get a precise rejection (not the generic "Unknown
637
+ // option") so callers know the api backend refuses them on purpose.
638
+ if (argv.includes("--yolo")) {
639
+ return {
640
+ error: "api backend does not support --yolo (agent flag; api has no agent)",
641
+ };
642
+ }
643
+ if (argv.includes("--sandbox")) {
644
+ return {
645
+ error: "api backend does not support --sandbox (agent flag; api has no agent)",
646
+ };
647
+ }
648
+ try {
649
+ const { values } = parseArgs({
650
+ args: argv,
651
+ options: {
652
+ prompt: { short: "p", type: "string" },
653
+ model: { type: "string" },
654
+ provider: { type: "string" },
655
+ "max-tokens": { type: "string" },
656
+ timeout: { type: "string" },
657
+ stream: { type: "boolean" },
658
+ version: { type: "boolean" },
659
+ help: { type: "boolean" },
660
+ cwd: { type: "string" },
661
+ },
662
+ strict: true,
663
+ allowNegative: true,
664
+ });
665
+ let maxTokens = API_DEFAULT_MAX_TOKENS;
666
+ if (values["max-tokens"] !== undefined) {
667
+ const n = Number(values["max-tokens"]);
668
+ if (!Number.isFinite(n) || n < 1 || n > 200_000) {
669
+ return {
670
+ error: `--max-tokens must be a number in [1, 200000], got "${values["max-tokens"]}"`,
671
+ };
672
+ }
673
+ maxTokens = n;
674
+ }
675
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
676
+ if (values.timeout !== undefined) {
677
+ const n = Number(values.timeout);
678
+ if (!Number.isFinite(n) || n < 1000 || n > 1_800_000) {
679
+ return {
680
+ error: `--timeout must be a number in [1000, 1800000], got "${values.timeout}"`,
681
+ };
682
+ }
683
+ timeoutMs = n;
684
+ }
685
+ return {
686
+ prompt: typeof values.prompt === "string" ? values.prompt : undefined,
687
+ model: typeof values.model === "string" ? values.model : undefined,
688
+ provider: typeof values.provider === "string" ? values.provider : undefined,
689
+ maxTokens,
690
+ timeoutMs,
691
+ // default stream=true; --no-stream (allowNegative) → false
692
+ stream: values.stream !== false,
693
+ version: values.version === true,
694
+ help: values.help === true,
695
+ cwd: typeof values.cwd === "string" ? values.cwd : undefined,
696
+ };
697
+ }
698
+ catch (err) {
699
+ return { error: err instanceof Error ? err.message : String(err) };
700
+ }
701
+ }
702
+ /** True when a ParseApiResult is the ok variant. */
703
+ function isApiOk(r) {
704
+ return !("error" in r);
705
+ }
706
+ // ---------------------------------------------------------------------------
707
+ // Spawn
708
+ // ---------------------------------------------------------------------------
709
+ export function runAgy(args, timeoutMs, cwd) {
710
+ return new Promise((resolveFn) => {
711
+ const child = spawn(AGY_BIN, args, {
712
+ cwd,
713
+ env: { ...process.env },
714
+ // inherit stdin so `agy -p -` can read a piped prompt through gcli
715
+ stdio: ["inherit", "pipe", "pipe"],
716
+ });
717
+ let stdout = "";
718
+ let stderr = "";
719
+ let timedOut = false;
720
+ child.stdout.on("data", (chunk) => {
721
+ stdout += chunk.toString();
722
+ });
723
+ child.stderr.on("data", (chunk) => {
724
+ stderr += chunk.toString();
725
+ });
726
+ const timer = setTimeout(() => {
727
+ timedOut = true;
728
+ child.kill("SIGTERM");
729
+ }, timeoutMs);
730
+ child.on("error", () => {
731
+ clearTimeout(timer);
732
+ resolveFn({ stdout, stderr, exitCode: null, timedOut: false });
733
+ });
734
+ child.on("close", (code, signal) => {
735
+ clearTimeout(timer);
736
+ resolveFn({
737
+ stdout,
738
+ stderr,
739
+ exitCode: code,
740
+ signal: signal ?? null,
741
+ timedOut,
742
+ });
743
+ });
744
+ });
745
+ }
746
+ /**
747
+ * Spawn the claude CLI with the same IO/timeout contract as `runAgy` (C6/C9).
748
+ * stdin is inherited so a piped prompt reaches claude when gcli passes `-p -`
749
+ * through. SIGTERM enforces the timeout deterministically.
750
+ */
751
+ export function runClaude(args, timeoutMs, cwd) {
752
+ return new Promise((resolveFn) => {
753
+ const child = spawn(CLAUDE_BIN, args, {
754
+ cwd,
755
+ env: { ...process.env },
756
+ stdio: ["inherit", "pipe", "pipe"],
757
+ });
758
+ let stdout = "";
759
+ let stderr = "";
760
+ let timedOut = false;
761
+ child.stdout.on("data", (chunk) => {
762
+ stdout += chunk.toString();
763
+ });
764
+ child.stderr.on("data", (chunk) => {
765
+ stderr += chunk.toString();
766
+ });
767
+ const timer = setTimeout(() => {
768
+ timedOut = true;
769
+ child.kill("SIGTERM");
770
+ }, timeoutMs);
771
+ child.on("error", () => {
772
+ clearTimeout(timer);
773
+ resolveFn({ stdout, stderr, exitCode: null, timedOut: false });
774
+ });
775
+ child.on("close", (code, signal) => {
776
+ clearTimeout(timer);
777
+ resolveFn({
778
+ stdout,
779
+ stderr,
780
+ exitCode: code,
781
+ signal: signal ?? null,
782
+ timedOut,
783
+ });
784
+ });
785
+ });
786
+ }
787
+ /**
788
+ * Spawn a backend with stdio fully inherited (interactive TUI mode).
789
+ *
790
+ * Unlike runAgy/runClaude: no timeout, no stdout/stderr capture, no
791
+ * truncation — the child owns the terminal. The child's exit code is passed
792
+ * through unchanged (SIGINT→130, SIGTERM→143 per shell convention).
793
+ */
794
+ function spawnInteractive(bin, args, cwd) {
795
+ return new Promise((resolveFn) => {
796
+ const child = spawn(bin, args, {
797
+ cwd,
798
+ env: { ...process.env },
799
+ stdio: "inherit",
800
+ });
801
+ child.on("error", (err) => {
802
+ resolveFn({
803
+ exitCode: 1,
804
+ spawnError: err.code === "ENOENT"
805
+ ? `${bin} binary not found on PATH`
806
+ : `failed to spawn ${bin}: ${err.message}`,
807
+ });
808
+ });
809
+ child.on("close", (code, signal) => {
810
+ const exitCode = code ?? (signal ? 128 + signoFromSignal(signal) : 1);
811
+ resolveFn({ exitCode, signal: signal ?? null });
812
+ });
813
+ });
814
+ }
815
+ /** Map a signal name to its conventional shell exit code (128 + signo). */
816
+ function signoFromSignal(signal) {
817
+ const map = {
818
+ SIGHUP: 1,
819
+ SIGINT: 2,
820
+ SIGQUIT: 3,
821
+ SIGABRT: 6,
822
+ SIGKILL: 9,
823
+ SIGTERM: 15,
824
+ };
825
+ return map[signal] ?? 2; // default to INT (130) for unknown signals
826
+ }
827
+ /** Spawn agy in interactive TUI mode (no -p, inherited stdio). */
828
+ export function runAgyInteractive(args, cwd) {
829
+ return spawnInteractive(AGY_BIN, args, cwd);
830
+ }
831
+ /** Spawn claude in interactive TUI mode (no -p, inherited stdio). */
832
+ export function runClaudeInteractive(args, cwd) {
833
+ return spawnInteractive(CLAUDE_BIN, args, cwd);
834
+ }
835
+ // ---------------------------------------------------------------------------
836
+ // api backend — production HTTP implementation (zero deps: fetch + TextDecoder)
837
+ // ---------------------------------------------------------------------------
838
+ /**
839
+ * Production deps.runApi: POST an anthropic-compatible /v1/messages request
840
+ * and return a normalized RunOutcome.
841
+ *
842
+ * - stream=true: reads the SSE body chunk-by-chunk, decodes UTF-8, splits on
843
+ * newlines, and aggregates `text_delta` payloads into stdout. Two clocks
844
+ * guard against hangs: an idle timer (reset on every text-bearing chunk)
845
+ * and an absolute timer (timeoutMs). Either firing aborts the fetch via
846
+ * AbortController → exit 1 with a timeout message.
847
+ * - stream=false: awaits the full JSON body and extracts `content[].text`,
848
+ * then applies the 50k-char truncation.
849
+ *
850
+ * HTTP errors (non-2xx, network failure, abort) → exit 1 with diagnostics on
851
+ * stderr; stdout stays empty so the caller's empty-output guard still works.
852
+ */
853
+ export async function runApi(req) {
854
+ const controller = new AbortController();
855
+ const { signal } = controller;
856
+ // Two timers: absolute + idle. timeoutMs is both the hard ceiling and the
857
+ // default idle budget — SSE streams that go quiet for that long are treated
858
+ // as hung. We reset the idle clock whenever we receive text.
859
+ let idleTimer;
860
+ let absoluteTimer;
861
+ let timedOut = false;
862
+ const resetIdle = () => {
863
+ if (idleTimer)
864
+ clearTimeout(idleTimer);
865
+ idleTimer = setTimeout(() => {
866
+ timedOut = true;
867
+ controller.abort();
868
+ }, req.timeoutMs);
869
+ };
870
+ absoluteTimer = setTimeout(() => {
871
+ timedOut = true;
872
+ controller.abort();
873
+ }, req.timeoutMs);
874
+ resetIdle();
875
+ const clearTimers = () => {
876
+ if (idleTimer)
877
+ clearTimeout(idleTimer);
878
+ if (absoluteTimer)
879
+ clearTimeout(absoluteTimer);
880
+ };
881
+ const headers = {
882
+ Authorization: `Bearer ${req.token}`,
883
+ "anthropic-version": "2023-06-01",
884
+ "content-type": "application/json",
885
+ accept: req.stream ? "text/event-stream" : "application/json",
886
+ };
887
+ let response;
888
+ try {
889
+ response = await fetch(req.url, {
890
+ method: "POST",
891
+ headers,
892
+ body: JSON.stringify(buildApiBody(req)),
893
+ signal,
894
+ });
895
+ }
896
+ catch (err) {
897
+ clearTimers();
898
+ if (timedOut) {
899
+ return {
900
+ exitCode: 1,
901
+ stdout: "",
902
+ stderr: `gcli: api timed out after ${req.timeoutMs}ms`,
903
+ };
904
+ }
905
+ const msg = err instanceof Error ? err.message : String(err);
906
+ return {
907
+ exitCode: 1,
908
+ stdout: "",
909
+ stderr: `gcli: api request failed: ${msg}`,
910
+ };
911
+ }
912
+ if (!response.ok) {
913
+ clearTimers();
914
+ let detail = "";
915
+ try {
916
+ detail = await response.text();
917
+ }
918
+ catch {
919
+ detail = "";
920
+ }
921
+ const trimmed = detail.trim().slice(0, 1000);
922
+ return {
923
+ exitCode: 1,
924
+ stdout: "",
925
+ stderr: `gcli: api returned HTTP ${response.status}${trimmed ? `: ${trimmed}` : ""}`,
926
+ };
927
+ }
928
+ if (!req.stream) {
929
+ clearTimers();
930
+ let body;
931
+ try {
932
+ body = await response.json();
933
+ }
934
+ catch (err) {
935
+ const msg = err instanceof Error ? err.message : String(err);
936
+ return {
937
+ exitCode: 1,
938
+ stdout: "",
939
+ stderr: `gcli: api returned malformed JSON: ${msg}`,
940
+ };
941
+ }
942
+ const text = extractNonStreamText(body);
943
+ if (!text) {
944
+ return {
945
+ exitCode: 1,
946
+ stdout: "",
947
+ stderr: "gcli: api returned no text content",
948
+ };
949
+ }
950
+ return { exitCode: 0, stdout: truncate(text), stderr: "" };
951
+ }
952
+ // Streaming: aggregate text_delta chunks. response.body is a web stream;
953
+ // TextDecoder handles multi-byte UTF-8 split across chunk boundaries, and a
954
+ // leftover buffer carries the partial final line until the next newline.
955
+ if (response.body === null) {
956
+ clearTimers();
957
+ return { exitCode: 1, stdout: "", stderr: "gcli: api stream had no body" };
958
+ }
959
+ const reader = response.body.getReader();
960
+ const decoder = new TextDecoder("utf-8");
961
+ let aggregated = "";
962
+ let leftover = "";
963
+ try {
964
+ // eslint-disable-next-line no-constant-condition
965
+ while (true) {
966
+ const { done, value } = await reader.read();
967
+ if (done)
968
+ break;
969
+ leftover += decoder.decode(value, { stream: true });
970
+ // SSE events are separated by newlines; process every complete line and
971
+ // keep the trailing partial in `leftover`.
972
+ const lines = leftover.split(/\r?\n/);
973
+ leftover = lines.pop() ?? "";
974
+ for (const line of lines) {
975
+ const delta = extractTextDelta(line);
976
+ if (delta !== null) {
977
+ aggregated += delta;
978
+ resetIdle();
979
+ }
980
+ }
981
+ }
982
+ // Flush any trailing line (some servers omit the final newline).
983
+ const tail = decoder.decode();
984
+ leftover += tail;
985
+ if (leftover.length > 0) {
986
+ const delta = extractTextDelta(leftover);
987
+ if (delta !== null)
988
+ aggregated += delta;
989
+ }
990
+ }
991
+ catch (err) {
992
+ clearTimers();
993
+ if (timedOut) {
994
+ // Timeout is a backend error (exit 1) per the contract, even when some
995
+ // text was already received — callers must not treat a timed-out
996
+ // response as success.
997
+ return {
998
+ exitCode: 1,
999
+ stdout: "",
1000
+ stderr: `gcli: api timed out after ${req.timeoutMs}ms`,
1001
+ };
1002
+ }
1003
+ const msg = err instanceof Error ? err.message : String(err);
1004
+ return {
1005
+ exitCode: 1,
1006
+ stdout: "",
1007
+ stderr: `gcli: api stream read failed: ${msg}`,
1008
+ };
1009
+ }
1010
+ clearTimers();
1011
+ if (!aggregated) {
1012
+ return {
1013
+ exitCode: 1,
1014
+ stdout: "",
1015
+ stderr: "gcli: api stream produced no text",
1016
+ };
1017
+ }
1018
+ // Streaming output is not truncated (real-time, contract §A); only the
1019
+ // non-stream path truncates.
1020
+ return { exitCode: 0, stdout: aggregated, stderr: "" };
1021
+ }
1022
+ // ---------------------------------------------------------------------------
1023
+ // cc-switch provider lookup
1024
+ // ---------------------------------------------------------------------------
1025
+ /**
1026
+ * Read all `app_type='claude'` providers from cc-switch.db (C4b/C4c).
1027
+ *
1028
+ * Uses `sqlite3 -readonly -json` (never opens the DB for write). Matching
1029
+ * stays in the pure `matchProviderName` so it is unit-testable without a DB.
1030
+ *
1031
+ * Errors are classified for the caller: sqlite-missing (binary not on PATH),
1032
+ * db-missing (sqlite3 exited non-zero, e.g. file absent), parse (bad JSON).
1033
+ */
1034
+ export function readCcSwitchProvider(dbPath) {
1035
+ return new Promise((resolve) => {
1036
+ const sql = "SELECT name, settings_config FROM providers WHERE app_type='claude'";
1037
+ const child = spawn("sqlite3", ["-readonly", "-json", dbPath, sql], {
1038
+ stdio: ["ignore", "pipe", "pipe"],
1039
+ });
1040
+ let stdout = "";
1041
+ let stderr = "";
1042
+ child.stdout.on("data", (chunk) => {
1043
+ stdout += chunk.toString();
1044
+ });
1045
+ child.stderr.on("data", (chunk) => {
1046
+ stderr += chunk.toString();
1047
+ });
1048
+ child.on("error", (err) => {
1049
+ resolve({
1050
+ ok: false,
1051
+ kind: "sqlite-missing",
1052
+ message: err.code === "ENOENT"
1053
+ ? "sqlite3 binary not found on PATH"
1054
+ : `sqlite3 failed to spawn: ${err.message}`,
1055
+ });
1056
+ });
1057
+ child.on("close", (code) => {
1058
+ if (code !== 0) {
1059
+ const msg = stderr.trim();
1060
+ resolve({
1061
+ ok: false,
1062
+ kind: "db-missing",
1063
+ message: msg
1064
+ ? `cc-switch db error: ${msg}`
1065
+ : `cc-switch db error: sqlite3 exited with code ${code}`,
1066
+ });
1067
+ return;
1068
+ }
1069
+ const trimmed = stdout.trim();
1070
+ if (!trimmed) {
1071
+ resolve({ ok: true, providers: [] });
1072
+ return;
1073
+ }
1074
+ try {
1075
+ const parsed = JSON.parse(trimmed);
1076
+ if (!Array.isArray(parsed)) {
1077
+ resolve({
1078
+ ok: false,
1079
+ kind: "parse",
1080
+ message: "sqlite3 returned non-array JSON",
1081
+ });
1082
+ return;
1083
+ }
1084
+ // cc-switch stores settings_config as a JSON string; expose it as
1085
+ // settingsConfig for the caller (extractProviderEnv parses it).
1086
+ const providers = parsed.map((r) => ({
1087
+ name: r.name,
1088
+ settingsConfig: r.settings_config,
1089
+ }));
1090
+ resolve({ ok: true, providers });
1091
+ }
1092
+ catch (err) {
1093
+ resolve({
1094
+ ok: false,
1095
+ kind: "parse",
1096
+ message: `sqlite3 returned malformed JSON: ${err instanceof Error ? err.message : String(err)}`,
1097
+ });
1098
+ }
1099
+ });
1100
+ });
1101
+ }
1102
+ /**
1103
+ * Validate a provider name against the allowlist (C4b). Returns the name, or
1104
+ * an error object suitable for an exit-2 stderr line.
1105
+ */
1106
+ export function validateProviderName(name) {
1107
+ if (!PROVIDER_NAME_RE.test(name)) {
1108
+ return {
1109
+ error: `invalid provider name: "${name}" (allowed: letters, digits, space, &._-)`,
1110
+ };
1111
+ }
1112
+ return name;
1113
+ }
1114
+ // ---------------------------------------------------------------------------
1115
+ // stdin
1116
+ // ---------------------------------------------------------------------------
1117
+ /**
1118
+ * Read all of stdin as a UTF-8 string.
1119
+ *
1120
+ * Both backends have a `-p -` blind spot (they take the literal "-"), so gcli
1121
+ * drains stdin itself and passes the content as an explicit `-p <text>` argv.
1122
+ * Resolves to "" on a TTY (nothing piped).
1123
+ */
1124
+ function readStdin() {
1125
+ return new Promise((resolve) => {
1126
+ if (process.stdin.isTTY) {
1127
+ resolve("");
1128
+ return;
1129
+ }
1130
+ let data = "";
1131
+ process.stdin.setEncoding("utf8");
1132
+ process.stdin.on("data", (chunk) => {
1133
+ data += chunk;
1134
+ });
1135
+ process.stdin.on("end", () => resolve(data));
1136
+ process.stdin.on("error", () => resolve(data));
1137
+ });
1138
+ }
1139
+ // ---------------------------------------------------------------------------
1140
+ // Provider picker (TTY only) — arrow keys, zero deps
1141
+ // ---------------------------------------------------------------------------
1142
+ /**
1143
+ * Production deps.pickProvider: arrow-key menu rendered entirely on stderr
1144
+ * (stdout stays pipe-clean). ↑↓/j/k move with wrap, Enter confirms, Esc
1145
+ * skips, ctrl-c restores the terminal then exits 130; any other key is a
1146
+ * noop (C-P1).
1147
+ *
1148
+ * Zero deps: `readline.emitKeypressEvents` + raw-mode stdin + hand-written
1149
+ * ANSI. Rows are redrawn in place (cursor-up + `\r` + clear-to-EOL per line)
1150
+ * so navigation leaves no ghosting (C-P3). The trailing 不切换 row is
1151
+ * appended here — `entries` holds providers only (D4); confirming it (or
1152
+ * pressing Esc) resolves {kind:"skip"}.
1153
+ *
1154
+ * Raw-mode lifecycle: `process.stdin.isRaw` is saved before
1155
+ * `setRawMode(true)` and restored on EVERY exit path (confirm / Esc / ctrl-c
1156
+ * / stream end / error) before the promise settles, and the keypress (and
1157
+ * its lazily-attached internal `data`) listeners are removed — so the
1158
+ * spawned claude TUI takes stdin over cleanly. The picker always completes
1159
+ * before any backend spawn.
1160
+ */
1161
+ function pickProviderInteractive(entries, initialIndex) {
1162
+ return new Promise((resolvePromise) => {
1163
+ const stderr = process.stderr;
1164
+ const stdin = process.stdin;
1165
+ const rowCount = entries.length + 1; // D1: count includes the 不切换 row
1166
+ let index = Math.min(Math.max(Math.trunc(initialIndex), 0), rowCount - 1); // clamp (D4)
1167
+ let settled = false;
1168
+ const SKIP_LABEL = "不切换(使用 cc-switch 当前生效配置)";
1169
+ const TITLE = "gcli: 选择 cc-switch provider(↑↓/j/k/C-n/C-p 移动 · Enter 确认 · Esc/C-g 不切换):";
1170
+ const labelOf = (row) => {
1171
+ if (row >= entries.length)
1172
+ return SKIP_LABEL;
1173
+ const entry = entries[row];
1174
+ return entry.quota ? `${entry.name} ${entry.quota}` : entry.name;
1175
+ };
1176
+ // Selected row: ❯ + full-line inverse video; unselected: two-space indent
1177
+ // (C-P7). \x1b[K clears to EOL so a shorter previous render leaves no
1178
+ // ghosting (残影).
1179
+ const rowText = (row) => {
1180
+ const label = labelOf(row);
1181
+ return row === index
1182
+ ? `\x1b[7m❯ ${label}\x1b[27m\x1b[K`
1183
+ : ` ${label}\x1b[K`;
1184
+ };
1185
+ const drawRows = () => {
1186
+ for (let row = 0; row < rowCount; row++) {
1187
+ stderr.write(`${rowText(row)}\n`);
1188
+ }
1189
+ };
1190
+ // In-place redraw: the cursor sits just below the last row after each
1191
+ // draw, so move it back up over every entry row before repainting.
1192
+ const redraw = () => {
1193
+ stderr.write(`\x1b[${rowCount}A\r`);
1194
+ drawRows();
1195
+ };
1196
+ // Raw-mode lifecycle (C-P3): save → raw → ... EVERY exit path restores.
1197
+ const wasRaw = stdin.isRaw === true;
1198
+ const dataListenersBefore = stdin.listeners("data");
1199
+ let onKeypress;
1200
+ let onGone = () => { };
1201
+ const cleanup = () => {
1202
+ if (onKeypress !== undefined) {
1203
+ stdin.removeListener("keypress", onKeypress);
1204
+ }
1205
+ stdin.removeListener("close", onGone);
1206
+ stdin.removeListener("error", onGone);
1207
+ // emitKeypressEvents lazily attaches an internal 'data' listener (via
1208
+ // its newListener hook) when the first keypress listener registers;
1209
+ // remove any data listeners we introduced so stdin is left exactly as
1210
+ // we found it and the spawned backend owns the terminal.
1211
+ for (const listener of stdin.listeners("data")) {
1212
+ if (!dataListenersBefore.includes(listener)) {
1213
+ stdin.removeListener("data", listener);
1214
+ }
1215
+ }
1216
+ stdin.pause();
1217
+ if (stdin.isTTY)
1218
+ stdin.setRawMode(wasRaw);
1219
+ };
1220
+ const finish = (outcome) => {
1221
+ if (settled)
1222
+ return;
1223
+ settled = true;
1224
+ cleanup();
1225
+ resolvePromise(outcome);
1226
+ };
1227
+ // Title + hint drawn once; entry rows below it (C-P7).
1228
+ stderr.write(`${TITLE}\n`);
1229
+ drawRows();
1230
+ emitKeypressEvents(stdin);
1231
+ onKeypress = (_str, key) => {
1232
+ try {
1233
+ if (key?.ctrl && key.name === "c") {
1234
+ // C-P1: restore the terminal FIRST, then take the SIGINT exit code.
1235
+ cleanup();
1236
+ process.exit(130);
1237
+ }
1238
+ if (key === undefined)
1239
+ return;
1240
+ const action = applyPickerKey(key, index, rowCount);
1241
+ if (action.type === "move") {
1242
+ index = action.index;
1243
+ redraw();
1244
+ }
1245
+ else if (action.type === "confirm") {
1246
+ // The last row is the 不切换 row → skip (D4).
1247
+ finish(index < entries.length
1248
+ ? { kind: "select", entry: entries[index] }
1249
+ : { kind: "skip" });
1250
+ }
1251
+ else if (action.type === "skip") {
1252
+ finish({ kind: "skip" });
1253
+ }
1254
+ // noop → nothing
1255
+ }
1256
+ catch {
1257
+ // Any unexpected failure must never wedge raw mode on: restore and
1258
+ // treat like a skip.
1259
+ finish({ kind: "skip" });
1260
+ }
1261
+ };
1262
+ // Defensive exit paths (EOF after `-p -`, terminal hangup): restore and
1263
+ // skip rather than hang.
1264
+ onGone = () => finish({ kind: "skip" });
1265
+ stdin.on("keypress", onKeypress);
1266
+ stdin.once("close", onGone);
1267
+ stdin.once("error", onGone);
1268
+ if (stdin.isTTY)
1269
+ stdin.setRawMode(true);
1270
+ });
1271
+ }
1272
+ // ---------------------------------------------------------------------------
1273
+ // Last-provider memory (D2) — production deps.readLastProvider/writeLastProvider
1274
+ // ---------------------------------------------------------------------------
1275
+ /**
1276
+ * Production deps.readLastProvider: trimmed first line of LAST_PROVIDER_PATH,
1277
+ * or undefined when missing/empty/unreadable (silent — the memory is a hint,
1278
+ * never a warning).
1279
+ */
1280
+ function readLastProviderFromDisk() {
1281
+ return new Promise((resolvePromise) => {
1282
+ try {
1283
+ const raw = readFileSync(LAST_PROVIDER_PATH, "utf8");
1284
+ const trimmed = raw.trim();
1285
+ resolvePromise(trimmed ? trimmed : undefined);
1286
+ }
1287
+ catch {
1288
+ resolvePromise(undefined);
1289
+ }
1290
+ });
1291
+ }
1292
+ /**
1293
+ * Production deps.writeLastProvider: persist the provider name as a single
1294
+ * UTF-8 line, creating the config directory if needed. Best-effort: any
1295
+ * failure is swallowed (memory is an optimization, never an error).
1296
+ */
1297
+ function writeLastProviderToDisk(name) {
1298
+ return new Promise((resolvePromise) => {
1299
+ try {
1300
+ mkdirSync(dirname(LAST_PROVIDER_PATH), { recursive: true });
1301
+ writeFileSync(LAST_PROVIDER_PATH, `${name}\n`, "utf8");
1302
+ }
1303
+ catch {
1304
+ // best-effort: ignore
1305
+ }
1306
+ resolvePromise();
1307
+ });
1308
+ }
1309
+ /**
1310
+ * Production deps.fetchProviderQuotas (C-Q5): cache-first with a 60s/15s TTL,
1311
+ * concurrent fetch (2.5s AbortController per request) for stale entries,
1312
+ * best-effort cache rewrite. Failures resolve to no subtitle — the menu must
1313
+ * never block or error on quota problems.
1314
+ */
1315
+ async function fetchProviderQuotasHttp(items) {
1316
+ const out = new Map();
1317
+ let cache = {};
1318
+ try {
1319
+ const raw = JSON.parse(readFileSync(QUOTA_CACHE_PATH, "utf8"));
1320
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
1321
+ cache = raw;
1322
+ }
1323
+ }
1324
+ catch {
1325
+ cache = {};
1326
+ }
1327
+ const now = Date.now();
1328
+ const stale = [];
1329
+ for (const item of items) {
1330
+ const c = cache[item.name];
1331
+ if (c !== undefined &&
1332
+ typeof c.ts === "number" &&
1333
+ now - c.ts < (c.ok ? QUOTA_TTL_OK_MS : QUOTA_TTL_FAIL_MS)) {
1334
+ if (c.ok === true && typeof c.text === "string" && c.text !== "") {
1335
+ out.set(item.name, c.text);
1336
+ }
1337
+ continue;
1338
+ }
1339
+ const req = buildQuotaRequest(item.env);
1340
+ if (req === null)
1341
+ continue; // no quota API for this provider — skip, don't cache
1342
+ stale.push({ name: item.name, req });
1343
+ }
1344
+ await Promise.all(stale.map(async ({ name, req }) => {
1345
+ const controller = new AbortController();
1346
+ const timer = setTimeout(() => controller.abort(), QUOTA_FETCH_TIMEOUT_MS);
1347
+ try {
1348
+ const headers = {
1349
+ Authorization: req.authHeader,
1350
+ Accept: "application/json",
1351
+ };
1352
+ if (req.kind === "glm")
1353
+ headers["Accept-Language"] = "en-US,en";
1354
+ const resp = await fetch(req.url, {
1355
+ headers,
1356
+ signal: controller.signal,
1357
+ });
1358
+ if (!resp.ok) {
1359
+ cache[name] = { ts: now, ok: false };
1360
+ return;
1361
+ }
1362
+ const body = await resp.json();
1363
+ const windows = req.kind === "kimi" ? parseKimiUsages(body) : parseGlmQuota(body);
1364
+ const text = formatQuota(windows, Date.now());
1365
+ if (text !== "") {
1366
+ cache[name] = { ts: now, ok: true, text };
1367
+ out.set(name, text);
1368
+ }
1369
+ else {
1370
+ cache[name] = { ts: now, ok: false };
1371
+ }
1372
+ }
1373
+ catch {
1374
+ cache[name] = { ts: now, ok: false };
1375
+ }
1376
+ finally {
1377
+ clearTimeout(timer);
1378
+ }
1379
+ }));
1380
+ try {
1381
+ mkdirSync(dirname(QUOTA_CACHE_PATH), { recursive: true });
1382
+ writeFileSync(QUOTA_CACHE_PATH, JSON.stringify(cache), "utf8");
1383
+ }
1384
+ catch {
1385
+ // best-effort: ignore
1386
+ }
1387
+ return out;
1388
+ }
1389
+ // ---------------------------------------------------------------------------
1390
+ // Main
1391
+ // ---------------------------------------------------------------------------
1392
+ const HELP = `Usage:
1393
+ gcli [claude] [options] [-- <args>] wrap the claude CLI (default backend)
1394
+ gcli agy [options] [-- <args>] wrap the agy CLI (explicit subcommand)
1395
+ gcli api [options] call an anthropic-compatible messages API
1396
+
1397
+ gcli sits in front of three backends and gives skills a stable entry point:
1398
+ 50k-char output truncation (agy/claude; api only when non-stream), a hard
1399
+ timeout, explicit exit codes, and stdin piping (\`-p -\`).
1400
+
1401
+ Without -p, the agy/claude backends launch their interactive TUI (inherited
1402
+ stdio; no timeout, no truncation; the child's exit code is passed through).
1403
+ This requires a TTY — piping into gcli without -p is an error (use '-p -' to
1404
+ pipe a prompt). The api backend is one-shot HTTP and always requires -p.
1405
+
1406
+ For agy/claude, unknown flags and bare args are forwarded to the backend
1407
+ verbatim; \`--\` forwards everything after it unconditionally. The api backend
1408
+ is STRICT — unknown flags are errors (exit 2), because it builds an HTTP body
1409
+ directly with nothing to forward to.
1410
+
1411
+ agy backend (\`gcli agy ...\` — the subcommand is REQUIRED; bare \`gcli\` is claude):
1412
+ -p, --prompt <text|-> Prompt text, "-" for stdin; omit for interactive TUI
1413
+ --model <name> agy model (e.g. gemini-2.5-pro)
1414
+ --yolo Auto-approve tool actions (agy --dangerously-skip-permissions)
1415
+ --sandbox Run agy in sandbox mode
1416
+ --cwd <dir> Working directory (added via agy --add-dir)
1417
+ --timeout <ms> Hard timeout in ms (default 300000, max 1800000)
1418
+ --version Print the agy version
1419
+ --help Show this help
1420
+ -- <args...> Pass remaining args through to agy verbatim
1421
+
1422
+ claude backend (default; bare \`gcli ...\` === \`gcli claude ...\`):
1423
+ -p, --prompt <text|-> Prompt text, "-" for stdin; omit for interactive TUI
1424
+ --provider <name> cc-switch provider (matched by exact/case/substring)
1425
+ --pick Force the provider picker menu, even in print mode
1426
+ --model <name> Override ANTHROPIC_MODEL in the provider env
1427
+ --cwd <dir> Working directory (added via claude --add-dir)
1428
+ --timeout <ms> Hard timeout in ms (default 300000, max 1800000)
1429
+ --version Print the claude version
1430
+ --help Show this help
1431
+ -- <args...> Pass remaining args through to claude verbatim
1432
+
1433
+ Notes:
1434
+ - --provider switches via \`claude -p ... --settings {'env':{...}}\`; it
1435
+ does NOT rewrite ~/.claude/settings.json.
1436
+ - No --provider in a TTY: gcli lists all cc-switch providers (in
1437
+ cc-switch's own DB order) in an arrow-key picker (↑↓/j/k and Emacs
1438
+ C-n/C-p move · Enter 确认 · Esc/C-g = 不切换, keep claude's default
1439
+ config; M-</M-> jump to first/last; ctrl-c exits 130). In print mode
1440
+ (-p) the last confirmed provider is reused silently (one stderr hint
1441
+ line); the menu only pops when nothing valid is remembered, or when
1442
+ --pick is given. Without a TTY the picker never triggers — no prompt,
1443
+ no cc-switch DB read, no memory-file IO.
1444
+ - Menu rows carry a quota subtitle (kimi/glm coding plans): 5h/weekly
1445
+ usage percent plus the next reset as a relative duration (e.g.
1446
+ \`5h:42% wk:17% ↻2h13m\`). Fetched once per menu open, cached 60s in
1447
+ ~/.config/gcli/quota-cache.json; providers without a quota API show
1448
+ no subtitle.
1449
+ - The last picker choice is remembered in ~/.config/gcli/last-provider
1450
+ (best-effort; explicit --provider neither reads nor writes it).
1451
+ - --yolo/--sandbox are rejected on the claude backend (default path
1452
+ included); use \`gcli agy\` for them. --pick is claude-only too (agy
1453
+ rejects it) and cannot be combined with --provider.
1454
+ - --provider passes the provider token via claude's argv (visible in 'ps');
1455
+ cc-switch's mechanism offers no sealed alternative.
1456
+
1457
+ api backend (\`gcli api ...\`) — pure HTTP, no agent, no subprocess:
1458
+ -p, --prompt <text|-> Prompt text, "-" for stdin (REQUIRED)
1459
+ --provider <name> cc-switch provider (REQUIRED; supplies base URL/token/model)
1460
+ --model <name> Override the provider's ANTHROPIC_MODEL
1461
+ --max-tokens <n> Output token budget (default 80000)
1462
+ --timeout <ms> Idle + absolute timeout in ms (default 300000, max 1800000)
1463
+ --stream|--no-stream Stream SSE and aggregate live (default stream)
1464
+ --version Print the api backend identity
1465
+ --help Show this help
1466
+
1467
+ Notes:
1468
+ - No --cwd (no file operations; supplied --cwd is warned + ignored).
1469
+ - --yolo/--sandbox are rejected (they are agent flags; api has no agent).
1470
+ - Unknown flags exit 2 (strict; nothing to forward to).
1471
+ - thinking is left ENABLED (k3 quality source); use a sufficient
1472
+ --max-tokens budget (default 80000) to cover thinking + text.
1473
+
1474
+ Exit codes: 0 success | 1 backend error / timeout / empty output | 2 bad args
1475
+ | N (interactive mode: child's exit code passed through unchanged)`;
1476
+ /** Map a backend spawn result to a gcli exit outcome (C2). */
1477
+ function mapSpawnResult(result, backend, timeoutMs) {
1478
+ if (result.timedOut) {
1479
+ return {
1480
+ exitCode: 1,
1481
+ stdout: "",
1482
+ stderr: `gcli: timed out after ${timeoutMs}ms`,
1483
+ };
1484
+ }
1485
+ const out = result.stdout.trim();
1486
+ if (result.exitCode !== 0) {
1487
+ const errInfo = result.stderr.trim() ||
1488
+ out ||
1489
+ `${backend} exited with code ${result.exitCode}`;
1490
+ return {
1491
+ exitCode: 1,
1492
+ stdout: "",
1493
+ stderr: `gcli: ${backend} failed (exit ${result.exitCode})\n${truncate(errInfo)}`,
1494
+ };
1495
+ }
1496
+ if (!out) {
1497
+ const info = result.stderr.trim()
1498
+ ? `stderr: ${truncate(result.stderr)}`
1499
+ : "no output";
1500
+ return {
1501
+ exitCode: 1,
1502
+ stdout: "",
1503
+ stderr: `gcli: ${backend} returned ${info}`,
1504
+ };
1505
+ }
1506
+ return { exitCode: 0, stdout: truncate(out), stderr: "" };
1507
+ }
1508
+ async function runAgyBackend(parsed, deps) {
1509
+ // --pick is claude-backend-only (cc-switch provider picker); agy has no
1510
+ // provider switching, so reject it like the other unsupported flags.
1511
+ if (parsed.pick) {
1512
+ return {
1513
+ exitCode: 2,
1514
+ stdout: "",
1515
+ stderr: "gcli: agy backend does not support --pick",
1516
+ };
1517
+ }
1518
+ if (parsed.version) {
1519
+ const r = await deps.runAgy(["--version"], VERSION_TIMEOUT_MS);
1520
+ const out = (r.stdout || r.stderr).trim();
1521
+ return {
1522
+ exitCode: r.exitCode === 0 ? 0 : 1,
1523
+ stdout: out,
1524
+ stderr: r.exitCode === 0 ? "" : "gcli: agy --version failed",
1525
+ };
1526
+ }
1527
+ // TTY guard: no -p in a non-TTY (pipe/CI) is an error; in a TTY it
1528
+ // launches the backend's interactive TUI.
1529
+ if (parsed.prompt === undefined && !deps.isInteractive()) {
1530
+ return {
1531
+ exitCode: 2,
1532
+ stdout: "",
1533
+ stderr: "gcli: -p/--prompt is required (run in a TTY for interactive mode, or use '-p -' for stdin)",
1534
+ };
1535
+ }
1536
+ let prompt = parsed.prompt;
1537
+ if (prompt === "-") {
1538
+ prompt = await deps.readStdin();
1539
+ if (!prompt.trim()) {
1540
+ return {
1541
+ exitCode: 2,
1542
+ stdout: "",
1543
+ stderr: "gcli: -p - given but stdin is empty (no prompt piped)",
1544
+ };
1545
+ }
1546
+ }
1547
+ const cwdAbs = parsed.cwd ? resolve(parsed.cwd) : undefined;
1548
+ // Interactive mode (no -p in a TTY): inherit stdio, pass exit code through.
1549
+ if (prompt === undefined) {
1550
+ const args = buildAgyArgs({
1551
+ model: parsed.model,
1552
+ yolo: parsed.yolo,
1553
+ sandbox: parsed.sandbox,
1554
+ cwd: parsed.cwd,
1555
+ timeoutMs: parsed.timeoutMs,
1556
+ passthrough: parsed.passthrough,
1557
+ });
1558
+ const r = await deps.runAgyInteractive(args, cwdAbs);
1559
+ return { exitCode: r.exitCode, stdout: "", stderr: r.spawnError ?? "" };
1560
+ }
1561
+ const opts = {
1562
+ prompt,
1563
+ model: parsed.model,
1564
+ yolo: parsed.yolo,
1565
+ sandbox: parsed.sandbox,
1566
+ cwd: parsed.cwd,
1567
+ timeoutMs: parsed.timeoutMs,
1568
+ passthrough: parsed.passthrough,
1569
+ };
1570
+ const args = buildAgyArgs(opts);
1571
+ const result = await deps.runAgy(args, parsed.timeoutMs, cwdAbs);
1572
+ return mapSpawnResult(result, "agy", parsed.timeoutMs);
1573
+ }
1574
+ /**
1575
+ * Shared tail of the claude provider paths (explicit --provider and the TTY
1576
+ * picker): RawProvider → settingsEnv (C-D4). cc-switch stores
1577
+ * settings_config as a JSON string; a pre-parsed object is accepted too
1578
+ * (defensive). Env-extraction failures map to exit 1, unchanged.
1579
+ */
1580
+ function settingsEnvFromRawProvider(target, model) {
1581
+ const cfgJson = typeof target.settingsConfig === "string"
1582
+ ? target.settingsConfig
1583
+ : JSON.stringify(target.settingsConfig);
1584
+ const envResult = extractProviderEnv(cfgJson);
1585
+ if ("error" in envResult) {
1586
+ return {
1587
+ kind: "error",
1588
+ outcome: { exitCode: 1, stdout: "", stderr: `gcli: ${envResult.error}` },
1589
+ };
1590
+ }
1591
+ return { kind: "ok", env: buildSettingsEnv(envResult.env, model) };
1592
+ }
1593
+ async function runClaudeBackend(parsed, deps) {
1594
+ // D3 validation order: --pick/--provider mutual exclusion → non-TTY --pick
1595
+ // → the pre-existing yolo/version/TTY-guard sequence.
1596
+ if (parsed.pick && parsed.provider !== undefined) {
1597
+ return {
1598
+ exitCode: 2,
1599
+ stdout: "",
1600
+ stderr: "gcli: --pick cannot be combined with --provider",
1601
+ };
1602
+ }
1603
+ // Placed BEFORE the TTY guard below on purpose: a non-TTY --pick must
1604
+ // report --pick even when -p is missing (which would otherwise produce the
1605
+ // generic "-p is required" error).
1606
+ if (parsed.pick && !deps.isInteractive()) {
1607
+ return {
1608
+ exitCode: 2,
1609
+ stdout: "",
1610
+ stderr: "gcli: --pick requires a TTY",
1611
+ };
1612
+ }
1613
+ // C8: claude backend rejects agy-only flags.
1614
+ if (parsed.yolo || parsed.sandbox) {
1615
+ return {
1616
+ exitCode: 2,
1617
+ stdout: "",
1618
+ stderr: "gcli: claude backend does not support --yolo/--sandbox",
1619
+ };
1620
+ }
1621
+ if (parsed.version) {
1622
+ const r = await deps.runClaude(["--version"], VERSION_TIMEOUT_MS);
1623
+ const out = (r.stdout || r.stderr).trim();
1624
+ return {
1625
+ exitCode: r.exitCode === 0 ? 0 : 1,
1626
+ stdout: out,
1627
+ stderr: r.exitCode === 0 ? "" : "gcli: claude --version failed",
1628
+ };
1629
+ }
1630
+ // TTY guard: no -p in a non-TTY is an error; in a TTY it launches claude's
1631
+ // interactive TUI (with provider injection honored).
1632
+ if (parsed.prompt === undefined && !deps.isInteractive()) {
1633
+ return {
1634
+ exitCode: 2,
1635
+ stdout: "",
1636
+ stderr: "gcli: -p/--prompt is required (run in a TTY for interactive mode, or use '-p -' for stdin)",
1637
+ };
1638
+ }
1639
+ let prompt = parsed.prompt;
1640
+ if (prompt === "-") {
1641
+ prompt = await deps.readStdin();
1642
+ if (!prompt.trim()) {
1643
+ return {
1644
+ exitCode: 2,
1645
+ stdout: "",
1646
+ stderr: "gcli: -p - given but stdin is empty (no prompt piped)",
1647
+ };
1648
+ }
1649
+ }
1650
+ let settingsEnv;
1651
+ let pickerWarning;
1652
+ if (parsed.provider !== undefined) {
1653
+ const validated = validateProviderName(parsed.provider);
1654
+ if (typeof validated !== "string") {
1655
+ return { exitCode: 2, stdout: "", stderr: `gcli: ${validated.error}` };
1656
+ }
1657
+ const lookup = await deps.readCcSwitchProvider();
1658
+ if (!lookup.ok) {
1659
+ return { exitCode: 1, stdout: "", stderr: `gcli: ${lookup.message}` };
1660
+ }
1661
+ const names = lookup.providers.map((p) => p.name);
1662
+ const match = matchProviderName(parsed.provider, names);
1663
+ if ("none" in match) {
1664
+ return {
1665
+ exitCode: 2,
1666
+ stdout: "",
1667
+ stderr: `gcli: provider not found: ${parsed.provider}`,
1668
+ };
1669
+ }
1670
+ if ("ambiguous" in match) {
1671
+ const candidates = match.ambiguous
1672
+ .map((n) => `"${n}"`)
1673
+ .sort()
1674
+ .join(", ");
1675
+ return {
1676
+ exitCode: 2,
1677
+ stdout: "",
1678
+ stderr: `gcli: ambiguous provider: ${candidates}`,
1679
+ };
1680
+ }
1681
+ const target = lookup.providers.find((p) => p.name === match.matched);
1682
+ if (target === undefined) {
1683
+ return {
1684
+ exitCode: 2,
1685
+ stdout: "",
1686
+ stderr: `gcli: provider not found: ${parsed.provider}`,
1687
+ };
1688
+ }
1689
+ const resolved = settingsEnvFromRawProvider(target, parsed.model);
1690
+ if (resolved.kind === "error") {
1691
+ return resolved.outcome;
1692
+ }
1693
+ settingsEnv = resolved.env;
1694
+ }
1695
+ else {
1696
+ // Picker / memory path (D3 matrix): closed trigger set — claude dispatch
1697
+ // (we are here), no --provider, and past the --version short-circuit.
1698
+ // Non-TTY callers (skills/CI/pipes) skip this entirely: zero prompts,
1699
+ // zero cc-switch DB reads, zero memory-file IO (C-P9).
1700
+ if (deps.isInteractive()) {
1701
+ const lookup = await deps.readCcSwitchProvider();
1702
+ if (!lookup.ok) {
1703
+ // Soft degradation (C-P10): warn and continue without injection.
1704
+ pickerWarning = `gcli: provider picker unavailable: ${lookup.message}`;
1705
+ }
1706
+ else if (lookup.providers.length === 0) {
1707
+ pickerWarning = "gcli: no cc-switch providers configured";
1708
+ }
1709
+ else {
1710
+ // Memory read (D2): TTY claude path, no --provider. Exact-name match
1711
+ // against the current list; mismatch/missing/empty/unreadable is
1712
+ // silently ignored (initialIndex 0, no warning).
1713
+ const remembered = await deps.readLastProvider();
1714
+ // D5 (revise-2): NO sorting — the menu mirrors the cc-switch DB row
1715
+ // order (rowid/insertion order, what the cc-switch UI shows).
1716
+ const ordered = lookup.providers;
1717
+ const memoryIndex = remembered !== undefined
1718
+ ? ordered.findIndex((p) => p.name === remembered)
1719
+ : -1;
1720
+ const memoryValid = memoryIndex >= 0;
1721
+ const printMode = prompt !== undefined;
1722
+ // D3 matrix: TUI → always menu; print + valid memory + no --pick →
1723
+ // silent reuse; print + invalid/absent memory → menu; --pick →
1724
+ // force menu.
1725
+ const showMenu = parsed.pick || !printMode || !memoryValid;
1726
+ if (!showMenu) {
1727
+ // C-P5 silent reuse: inject the remembered provider with no menu
1728
+ // output; a single stderr hint line rides on outcome.stderr (same
1729
+ // mechanism as the v1 pickerWarning). Memory is NOT rewritten.
1730
+ const resolved = settingsEnvFromRawProvider(ordered[memoryIndex], parsed.model);
1731
+ if (resolved.kind === "error") {
1732
+ return resolved.outcome;
1733
+ }
1734
+ settingsEnv = resolved.env;
1735
+ pickerWarning = `gcli: provider=${ordered[memoryIndex].name}(--pick 重选)`;
1736
+ }
1737
+ else {
1738
+ // C-Q4: quota fetch ONLY on the menu path (silent reuse / explicit
1739
+ // --provider / non-TTY never reach here). Extract each provider's
1740
+ // env first (same source as injection); broken configs are skipped.
1741
+ const quotaItems = [];
1742
+ for (const p of ordered) {
1743
+ const cfgJson = typeof p.settingsConfig === "string"
1744
+ ? p.settingsConfig
1745
+ : JSON.stringify(p.settingsConfig);
1746
+ const envResult = extractProviderEnv(cfgJson);
1747
+ if (!("error" in envResult)) {
1748
+ quotaItems.push({ name: p.name, env: envResult.env });
1749
+ }
1750
+ }
1751
+ const quotaMap = await deps.fetchProviderQuotas(quotaItems);
1752
+ // D5/D6 (revise-3): rows are name + quota subtitle (host is gone);
1753
+ // the remembered row gets a(上次)marker after the subtitle.
1754
+ const entries = ordered.map((p) => {
1755
+ let quota = quotaMap.get(p.name);
1756
+ if (remembered !== undefined && p.name === remembered) {
1757
+ quota = quota !== undefined ? `${quota}(上次)` : "(上次)";
1758
+ }
1759
+ return { name: p.name, quota };
1760
+ });
1761
+ const picked = await deps.pickProvider(entries, memoryValid ? memoryIndex : 0);
1762
+ if (picked.kind === "select") {
1763
+ // Exact-name lookup (no matchProviderName): the picker returns
1764
+ // an entry straight from this list.
1765
+ const target = ordered.find((p) => p.name === picked.entry.name);
1766
+ if (target === undefined) {
1767
+ pickerWarning = `gcli: provider picker returned unknown name: ${picked.entry.name}`;
1768
+ }
1769
+ else {
1770
+ const resolved = settingsEnvFromRawProvider(target, parsed.model);
1771
+ if (resolved.kind === "error") {
1772
+ return resolved.outcome;
1773
+ }
1774
+ settingsEnv = resolved.env;
1775
+ // D2: write AFTER the picker confirm, BEFORE any spawn.
1776
+ await deps.writeLastProvider(picked.entry.name);
1777
+ }
1778
+ }
1779
+ // skip → keep claude's default config; memory untouched.
1780
+ }
1781
+ }
1782
+ }
1783
+ if (settingsEnv === undefined && parsed.model !== undefined) {
1784
+ // No provider injection (no --provider, picker skipped/unavailable):
1785
+ // still honour --model by injecting a minimal env that overrides
1786
+ // ANTHROPIC_MODEL via --settings merge (unchanged behaviour).
1787
+ settingsEnv = { ANTHROPIC_MODEL: parsed.model };
1788
+ }
1789
+ }
1790
+ // Picker degradation warnings ride along on the outcome's stderr without
1791
+ // changing the exit code (C-D5).
1792
+ const withPickerWarning = (o) => pickerWarning === undefined
1793
+ ? o
1794
+ : {
1795
+ ...o,
1796
+ stderr: o.stderr ? `${pickerWarning}\n${o.stderr}` : pickerWarning,
1797
+ };
1798
+ const cwdAbs = parsed.cwd ? resolve(parsed.cwd) : undefined;
1799
+ // Interactive mode (no -p in a TTY): inherit stdio, pass exit code through.
1800
+ if (prompt === undefined) {
1801
+ const args = buildClaudeArgs({
1802
+ settingsEnv,
1803
+ cwd: parsed.cwd,
1804
+ passthrough: parsed.passthrough,
1805
+ });
1806
+ const r = await deps.runClaudeInteractive(args, cwdAbs);
1807
+ return withPickerWarning({
1808
+ exitCode: r.exitCode,
1809
+ stdout: "",
1810
+ stderr: r.spawnError ?? "",
1811
+ });
1812
+ }
1813
+ const args = buildClaudeArgs({
1814
+ prompt,
1815
+ settingsEnv,
1816
+ cwd: parsed.cwd,
1817
+ passthrough: parsed.passthrough,
1818
+ });
1819
+ const result = await deps.runClaude(args, parsed.timeoutMs, cwdAbs);
1820
+ return withPickerWarning(mapSpawnResult(result, "claude", parsed.timeoutMs));
1821
+ }
1822
+ async function resolveApiProviderEnv(providerName, model, deps) {
1823
+ const err = (exitCode, stderr) => ({
1824
+ kind: "error",
1825
+ outcome: { exitCode, stdout: "", stderr },
1826
+ });
1827
+ if (providerName === undefined) {
1828
+ return err(2, "gcli: api backend requires --provider <name>");
1829
+ }
1830
+ const validated = validateProviderName(providerName);
1831
+ if (typeof validated !== "string") {
1832
+ return err(2, `gcli: ${validated.error}`);
1833
+ }
1834
+ const lookup = await deps.readCcSwitchProvider();
1835
+ if (!lookup.ok) {
1836
+ return err(1, `gcli: ${lookup.message}`);
1837
+ }
1838
+ const names = lookup.providers.map((p) => p.name);
1839
+ const match = matchProviderName(providerName, names);
1840
+ if ("none" in match) {
1841
+ return err(2, `gcli: provider not found: ${providerName}`);
1842
+ }
1843
+ if ("ambiguous" in match) {
1844
+ const candidates = match.ambiguous
1845
+ .map((n) => `"${n}"`)
1846
+ .sort()
1847
+ .join(", ");
1848
+ return err(2, `gcli: ambiguous provider: ${candidates}`);
1849
+ }
1850
+ const target = lookup.providers.find((p) => p.name === match.matched);
1851
+ if (target === undefined) {
1852
+ return err(2, `gcli: provider not found: ${providerName}`);
1853
+ }
1854
+ const cfgJson = typeof target.settingsConfig === "string"
1855
+ ? target.settingsConfig
1856
+ : JSON.stringify(target.settingsConfig);
1857
+ const envResult = extractProviderEnv(cfgJson);
1858
+ if ("error" in envResult) {
1859
+ return err(1, `gcli: ${envResult.error}`);
1860
+ }
1861
+ const baseUrl = envResult.env.ANTHROPIC_BASE_URL;
1862
+ const token = envResult.env.ANTHROPIC_AUTH_TOKEN;
1863
+ // Model resolution: --model > ANTHROPIC_MODEL > DEFAULT_SONNET > DEFAULT_HAIKU.
1864
+ // cc-switch stores context-window variants like "k3[1M]"; the [1M] marker is a
1865
+ // claude-agent convention the raw API rejects (HTTP 401 "model id does not
1866
+ // exist, recognized as other:k3[1M]"). Strip any trailing [...] suffix so
1867
+ // pure-API calls send "k3". Fallback chain also covers providers that only
1868
+ // define ANTHROPIC_DEFAULT_SONNET_MODEL (not ANTHROPIC_MODEL).
1869
+ const rawModel = model ??
1870
+ envResult.env.ANTHROPIC_MODEL ??
1871
+ envResult.env.ANTHROPIC_DEFAULT_SONNET_MODEL ??
1872
+ envResult.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
1873
+ const resolvedModel = rawModel?.replace(/\[[^\]]*\]$/, "");
1874
+ if (!baseUrl) {
1875
+ return err(1, "gcli: provider env is missing ANTHROPIC_BASE_URL");
1876
+ }
1877
+ if (!token) {
1878
+ return err(1, "gcli: provider env is missing ANTHROPIC_AUTH_TOKEN");
1879
+ }
1880
+ if (!resolvedModel) {
1881
+ return err(1, "gcli: no model resolved (set --model or ANTHROPIC_MODEL in provider)");
1882
+ }
1883
+ return { kind: "ok", baseUrl, token, model: resolvedModel };
1884
+ }
1885
+ /**
1886
+ * api backend entry (mirrors runClaudeBackend's shape). Strict argv, no
1887
+ * passthrough, no spawn. Resolves the provider, builds the ApiRequest, and
1888
+ * delegates to deps.runApi (HTTP + SSE + timeout).
1889
+ */
1890
+ async function runApiBackend(parsed, deps) {
1891
+ // --cwd is meaningless for the api backend (no file ops); warn into the
1892
+ // outcome's stderr (visible to callers/tests) rather than reject, per the
1893
+ // contract. main() forwards RunOutcome.stderr to the process.
1894
+ const cwdWarn = parsed.cwd !== undefined
1895
+ ? "gcli: --cwd is ignored by the api backend (no file operations)\n"
1896
+ : "";
1897
+ const wrap = (o) => ({
1898
+ ...o,
1899
+ stderr: cwdWarn + o.stderr,
1900
+ });
1901
+ if (parsed.version) {
1902
+ // No subprocess to query; report the api backend identity.
1903
+ return wrap({
1904
+ exitCode: 0,
1905
+ stdout: "gcli api backend (anthropic-compatible /v1/messages over HTTP)",
1906
+ stderr: "",
1907
+ });
1908
+ }
1909
+ // -p is required for the api backend (there is no interactive TUI mode —
1910
+ // it's a one-shot HTTP request).
1911
+ if (parsed.prompt === undefined) {
1912
+ return wrap({
1913
+ exitCode: 2,
1914
+ stdout: "",
1915
+ stderr: "gcli: api backend requires -p/--prompt <text|->",
1916
+ });
1917
+ }
1918
+ let prompt = parsed.prompt;
1919
+ if (prompt === "-") {
1920
+ prompt = await deps.readStdin();
1921
+ if (!prompt.trim()) {
1922
+ return wrap({
1923
+ exitCode: 2,
1924
+ stdout: "",
1925
+ stderr: "gcli: -p - given but stdin is empty (no prompt piped)",
1926
+ });
1927
+ }
1928
+ }
1929
+ const resolved = await resolveApiProviderEnv(parsed.provider, parsed.model, deps);
1930
+ if (resolved.kind === "error") {
1931
+ return wrap(resolved.outcome);
1932
+ }
1933
+ const req = {
1934
+ url: buildApiEndpoint(resolved.baseUrl),
1935
+ token: resolved.token,
1936
+ model: resolved.model,
1937
+ maxTokens: parsed.maxTokens,
1938
+ prompt,
1939
+ stream: parsed.stream,
1940
+ timeoutMs: parsed.timeoutMs,
1941
+ };
1942
+ const outcome = await deps.runApi(req);
1943
+ // Empty output is a backend error (exit 1), consistent with the other
1944
+ // backends' empty-output detection.
1945
+ if (outcome.exitCode === 0 && !outcome.stdout.trim()) {
1946
+ return wrap({
1947
+ exitCode: 1,
1948
+ stdout: "",
1949
+ stderr: "gcli: api returned no output",
1950
+ });
1951
+ }
1952
+ return wrap(outcome);
1953
+ }
1954
+ /**
1955
+ * Route argv to the agy / claude / api backend via injectable deps (C1/C2).
1956
+ * Returns a {exitCode, stdout, stderr} outcome; main() owns process.exit.
1957
+ */
1958
+ export async function run(argv, deps) {
1959
+ const sub = parseSubcommand(argv);
1960
+ if ("error" in sub) {
1961
+ return { exitCode: 2, stdout: "", stderr: `gcli: ${sub.error}` };
1962
+ }
1963
+ if (sub.subcommand === "api") {
1964
+ const parsed = parseApiArgs(sub.rest);
1965
+ if (!isApiOk(parsed)) {
1966
+ return { exitCode: 2, stdout: "", stderr: `gcli: ${parsed.error}` };
1967
+ }
1968
+ if (parsed.help) {
1969
+ return { exitCode: 0, stdout: HELP, stderr: "" };
1970
+ }
1971
+ return runApiBackend(parsed, deps);
1972
+ }
1973
+ const parsed = parseCliArgs(sub.rest);
1974
+ if (!isOk(parsed)) {
1975
+ return { exitCode: 2, stdout: "", stderr: `gcli: ${parsed.error}` };
1976
+ }
1977
+ if (parsed.help) {
1978
+ return { exitCode: 0, stdout: HELP, stderr: "" };
1979
+ }
1980
+ // Default backend is claude (C-D2): bare `gcli` === `gcli claude`.
1981
+ return sub.subcommand === "agy"
1982
+ ? runAgyBackend(parsed, deps)
1983
+ : runClaudeBackend(parsed, deps);
1984
+ }
1985
+ async function main() {
1986
+ const deps = {
1987
+ readCcSwitchProvider: () => readCcSwitchProvider(CC_SWITCH_DB_PATH),
1988
+ runClaude: (args, timeoutMs, cwd) => runClaude(args, timeoutMs ?? DEFAULT_TIMEOUT_MS, cwd),
1989
+ runAgy: (args, timeoutMs, cwd) => runAgy(args, timeoutMs ?? DEFAULT_TIMEOUT_MS, cwd),
1990
+ runApi: (req) => runApi(req),
1991
+ readStdin: () => readStdin(),
1992
+ runClaudeInteractive: (args, cwd) => runClaudeInteractive(args, cwd),
1993
+ runAgyInteractive: (args, cwd) => runAgyInteractive(args, cwd),
1994
+ isInteractive: () => process.stdin.isTTY === true,
1995
+ pickProvider: (entries, initialIndex) => pickProviderInteractive(entries, initialIndex),
1996
+ fetchProviderQuotas: (items) => fetchProviderQuotasHttp(items),
1997
+ readLastProvider: () => readLastProviderFromDisk(),
1998
+ writeLastProvider: (name) => writeLastProviderToDisk(name),
1999
+ };
2000
+ const r = await run(process.argv.slice(2), deps);
2001
+ if (r.stdout)
2002
+ process.stdout.write(`${r.stdout}\n`);
2003
+ if (r.stderr)
2004
+ process.stderr.write(`${r.stderr}\n`);
2005
+ process.exit(r.exitCode);
2006
+ }
2007
+ // realpathSync resolves the npm-link symlink so the guard holds when gcli is
2008
+ // run via the global bin, not just via its source path.
2009
+ const invokedDirectly = process.argv[1] !== undefined &&
2010
+ realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
2011
+ if (invokedDirectly) {
2012
+ main().catch((err) => {
2013
+ process.stderr.write(`gcli: fatal ${err instanceof Error ? err.message : String(err)}\n`);
2014
+ process.exit(1);
2015
+ });
2016
+ }
2017
+ //# sourceMappingURL=cli.js.map