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.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/README.zh-CN.md +88 -0
- package/package.json +54 -0
- package/packages/cli/dist/index.d.ts +2 -0
- package/packages/cli/dist/index.js +171 -0
- package/packages/cli/dist/index.js.map +1 -0
- package/packages/doubao/dist/cli.d.ts +38 -0
- package/packages/doubao/dist/cli.d.ts.map +1 -0
- package/packages/doubao/dist/cli.js +206 -0
- package/packages/gcli/dist/cli.d.ts +465 -0
- package/packages/gcli/dist/cli.js +2017 -0
- package/packages/gcli/dist/cli.js.map +1 -0
- package/packages/lmedia/dist/index.d.ts +1 -0
- package/packages/lmedia/dist/index.js +1594 -0
- package/packages/lmedia/python/edit.py +107 -0
- package/packages/lmedia/python/esrgan_path.py +16 -0
- package/packages/lmedia/python/gen.py +131 -0
- package/packages/lmedia/python/serve.py +352 -0
- package/packages/lmedia/python/sfx.py +527 -0
- package/packages/lmedia/python/teacache.py +255 -0
- package/packages/lmedia/python/upscale.py +41 -0
- package/packages/minimax/dist/cli.d.ts +51 -0
- package/packages/minimax/dist/cli.js +307 -0
- package/packages/minimax/dist/cli.js.map +1 -0
- package/packages/minimax/dist/client.d.ts +20 -0
- package/packages/minimax/dist/client.js +55 -0
- package/packages/minimax/dist/client.js.map +1 -0
- package/packages/minimax/dist/tts.d.ts +33 -0
- package/packages/minimax/dist/tts.js +64 -0
- package/packages/minimax/dist/tts.js.map +1 -0
- package/packages/minimax/dist/validate.d.ts +29 -0
- package/packages/minimax/dist/validate.js +122 -0
- package/packages/minimax/dist/validate.js.map +1 -0
- package/packages/minimax/dist/voice-clone.d.ts +17 -0
- package/packages/minimax/dist/voice-clone.js +47 -0
- package/packages/minimax/dist/voice-clone.js.map +1 -0
- package/packages/minimax/dist/voices.d.ts +17 -0
- package/packages/minimax/dist/voices.js +20 -0
- package/packages/minimax/dist/voices.js.map +1 -0
- package/packages/qwen/dist/index.d.ts +1 -0
- 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
|