honeydo 0.1.2 → 0.1.4
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/package.json +1 -1
- package/packages/gcli/dist/cli.d.ts +60 -13
- package/packages/gcli/dist/cli.js +275 -92
- package/packages/gcli/dist/cli.js.map +1 -1
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* `gcli agy ...` routes to the agy CLI (explicit subcommand required); the
|
|
7
7
|
* claude backend can switch cc-switch providers inline via
|
|
8
8
|
* `claude -p ... --settings`. Adds value over calling the backends directly:
|
|
9
|
-
* prompt via argv or stdin (`-p -`),
|
|
10
|
-
*
|
|
9
|
+
* prompt via argv or stdin (`-p -`), a hard timeout (spawn SIGTERM), explicit
|
|
10
|
+
* exit codes, and empty-output detection. Output is passed through unmodified
|
|
11
|
+
* — size limits are the endpoint's business, not ours.
|
|
11
12
|
*
|
|
12
13
|
* The claude backend resolves `--provider <name>` from the cc-switch SQLite
|
|
13
14
|
* DB (read-only) and never rewrites ~/.claude/settings.json — the provider
|
|
@@ -32,7 +33,6 @@ import { emitKeypressEvents } from "node:readline";
|
|
|
32
33
|
import { fileURLToPath } from "node:url";
|
|
33
34
|
import { parseArgs } from "node:util";
|
|
34
35
|
export const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes
|
|
35
|
-
export const CHARACTER_LIMIT = 50_000;
|
|
36
36
|
export const API_DEFAULT_MAX_TOKENS = 80000;
|
|
37
37
|
const AGY_BIN = "agy";
|
|
38
38
|
const CLAUDE_BIN = "claude";
|
|
@@ -125,12 +125,6 @@ export function parseSubcommand(argv) {
|
|
|
125
125
|
return { subcommand: undefined, rest: argv };
|
|
126
126
|
return { error: `unknown subcommand: ${first}` };
|
|
127
127
|
}
|
|
128
|
-
export function truncate(text) {
|
|
129
|
-
if (text.length <= CHARACTER_LIMIT)
|
|
130
|
-
return text;
|
|
131
|
-
return (text.slice(0, CHARACTER_LIMIT) +
|
|
132
|
-
`\n\n[Truncated — response exceeded ${CHARACTER_LIMIT} characters]`);
|
|
133
|
-
}
|
|
134
128
|
/** Translate gcli options into agy argv. Timeout is enforced by spawn kill.
|
|
135
129
|
* prompt is optional — when undefined, no -p is emitted (interactive mode). */
|
|
136
130
|
export function buildAgyArgs(opts) {
|
|
@@ -233,7 +227,7 @@ export function buildSettingsEnv(providerEnv, model) {
|
|
|
233
227
|
* - "return" / "enter" → confirm the current row (the physical Enter key
|
|
234
228
|
* emits "return" in raw mode; "enter" is the LF byte, which a tty may
|
|
235
229
|
* substitute for CR in input buffered before raw mode was enabled)
|
|
236
|
-
* - "escape" → skip (
|
|
230
|
+
* - "escape" → skip (退出,不启动后端)
|
|
237
231
|
* - Emacs (revise-2): ctrl+"n" ≡ down, ctrl+"p" ≡ up (same wrap); ctrl+"g"
|
|
238
232
|
* ≡ escape → skip; meta+"<" → first row (absolute), meta+">" → last row
|
|
239
233
|
* (absolute). Horizontal Emacs keys (C-f/C-b/C-a/C-e) and paging (C-v/M-v)
|
|
@@ -242,7 +236,7 @@ export function buildSettingsEnv(providerEnv, model) {
|
|
|
242
236
|
* exit 130)
|
|
243
237
|
*
|
|
244
238
|
* `index` is the highlighted row, `count` the total rendered rows INCLUDING
|
|
245
|
-
* the
|
|
239
|
+
* the footer row. Movement wraps with `(index±1+count)%count`; with
|
|
246
240
|
* count <= 0 moves are a noop (nothing is rendered).
|
|
247
241
|
*/
|
|
248
242
|
export function applyPickerKey(k, index, count) {
|
|
@@ -402,7 +396,9 @@ export function parseKimiUsages(body) {
|
|
|
402
396
|
}
|
|
403
397
|
/**
|
|
404
398
|
* Parse GLM `/api/monitor/usage/quota/limit` (C-Q2): data.limits[] entries
|
|
405
|
-
* with type
|
|
399
|
+
* with type "TOKENS_LIMIT" (coding-plan 订阅) or "CREDIT_LIMIT" (credit 资源
|
|
400
|
+
* 包) — billing type is per-account, fields are identical, same rendering
|
|
401
|
+
* (parity with statusline-sage); sorted by nextResetTime ascending — first is
|
|
406
402
|
* the short (5h) window, last the weekly one.
|
|
407
403
|
*/
|
|
408
404
|
export function parseGlmQuota(body) {
|
|
@@ -419,7 +415,7 @@ export function parseGlmQuota(body) {
|
|
|
419
415
|
if (typeof item !== "object" || item === null)
|
|
420
416
|
continue;
|
|
421
417
|
const r = item;
|
|
422
|
-
if (r.type !== "TOKENS_LIMIT")
|
|
418
|
+
if (r.type !== "TOKENS_LIMIT" && r.type !== "CREDIT_LIMIT")
|
|
423
419
|
continue;
|
|
424
420
|
const pct = toNum(r.percentage);
|
|
425
421
|
if (pct === undefined)
|
|
@@ -457,20 +453,90 @@ function formatReset(iso, nowMs) {
|
|
|
457
453
|
return rh > 0 ? `${d}d${rh}h` : `${d}d`;
|
|
458
454
|
}
|
|
459
455
|
/**
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
456
|
+
* Structured render input shared by the plain and the colored quota
|
|
457
|
+
* renderers: one percentage + one relative reset per window (each window's
|
|
458
|
+
* reset computed independently; missing/expired → omitted for that window).
|
|
459
|
+
* No windows at all → empty parts (the plain join then yields "").
|
|
460
|
+
*/
|
|
461
|
+
function quotaParts(q, nowMs) {
|
|
462
|
+
const shortPct = q.short?.pct;
|
|
463
|
+
const weeklyPct = q.weekly?.pct;
|
|
464
|
+
if (shortPct === undefined && weeklyPct === undefined)
|
|
465
|
+
return {};
|
|
466
|
+
return {
|
|
467
|
+
shortPct,
|
|
468
|
+
weeklyPct,
|
|
469
|
+
shortResetRel: q.short === undefined ? undefined : formatReset(q.short.resetIso, nowMs),
|
|
470
|
+
weeklyResetRel: q.weekly === undefined
|
|
471
|
+
? undefined
|
|
472
|
+
: formatReset(q.weekly.resetIso, nowMs),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Format quota windows as the menu subtitle (C-Q3, 2026-09 双窗重置):
|
|
477
|
+
* `5h:P% ↻<rel> wk:P% ↻<rel>` — each window carries its own reset time.
|
|
478
|
+
* Missing windows degrade; a missing/expired reset omits that window's ↻;
|
|
479
|
+
* no windows → "". Byte-exact contract with downstream consumers — the
|
|
480
|
+
* plain-text output is locked by unit tests; colors live in `colorQuota`,
|
|
481
|
+
* never here.
|
|
463
482
|
*/
|
|
464
483
|
export function formatQuota(q, nowMs) {
|
|
484
|
+
const p = quotaParts(q, nowMs);
|
|
465
485
|
const parts = [];
|
|
466
|
-
if (
|
|
467
|
-
parts.push(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
486
|
+
if (p.shortPct !== undefined) {
|
|
487
|
+
parts.push(p.shortResetRel === undefined
|
|
488
|
+
? `5h:${p.shortPct}%`
|
|
489
|
+
: `5h:${p.shortPct}% ↻${p.shortResetRel}`);
|
|
490
|
+
}
|
|
491
|
+
if (p.weeklyPct !== undefined) {
|
|
492
|
+
parts.push(p.weeklyResetRel === undefined
|
|
493
|
+
? `wk:${p.weeklyPct}%`
|
|
494
|
+
: `wk:${p.weeklyPct}% ↻${p.weeklyResetRel}`);
|
|
495
|
+
}
|
|
496
|
+
return parts.join(" ");
|
|
497
|
+
}
|
|
498
|
+
// Limit 染色阈值(对齐 statusline-sage 的 GLM_HIGH/GLM_MID)。
|
|
499
|
+
export const QUOTA_HIGH = 85;
|
|
500
|
+
export const QUOTA_MID = 60;
|
|
501
|
+
/** truecolor Sage 同款三色:#3A7D68 苔绿 / #D4920A 琥珀 / #D94F3D 朱红。 */
|
|
502
|
+
const COLOR_SAGE = "\x1b[38;2;58;125;104m";
|
|
503
|
+
const COLOR_AMBER = "\x1b[38;2;212;146;10m";
|
|
504
|
+
const COLOR_VERMILION = "\x1b[38;2;217;79;61m";
|
|
505
|
+
/** 关闭前景色回到终端默认,但不复位背景——选中行的整行高亮依赖这一点。 */
|
|
506
|
+
const FG_OFF = "\x1b[39m";
|
|
507
|
+
/**
|
|
508
|
+
* Limit 用量 → 段颜色:≥85 朱红、≥60 琥珀、其余(含非有限数)回退苔绿
|
|
509
|
+
* (阈值语义照搬 statusline-sage `_level_color`)。
|
|
510
|
+
*/
|
|
511
|
+
export function levelColor(pct) {
|
|
512
|
+
if (!Number.isFinite(pct))
|
|
513
|
+
return COLOR_SAGE;
|
|
514
|
+
if (pct >= QUOTA_HIGH)
|
|
515
|
+
return COLOR_VERMILION;
|
|
516
|
+
if (pct >= QUOTA_MID)
|
|
517
|
+
return COLOR_AMBER;
|
|
518
|
+
return COLOR_SAGE;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* `formatQuota` 的染色版(picker 用):`5h:P% ↻rel`/`wk:P% ↻rel` 每个窗口段
|
|
522
|
+
* 按各自 pct 独立选色,各自的重置时间恒 dim,不参与染色。结构与降级规则和
|
|
523
|
+
* `formatQuota` 一致(无窗口 → "")。行内使用 `\x1b[39m`/`\x1b[22m` 收尾而
|
|
524
|
+
* 非全复位,以免抹掉选中行背景。
|
|
525
|
+
*/
|
|
526
|
+
export function colorQuota(q, nowMs) {
|
|
527
|
+
const p = quotaParts(q, nowMs);
|
|
528
|
+
const parts = [];
|
|
529
|
+
if (p.shortPct !== undefined) {
|
|
530
|
+
parts.push(p.shortResetRel === undefined
|
|
531
|
+
? `${levelColor(p.shortPct)}5h:${p.shortPct}%${FG_OFF}`
|
|
532
|
+
: `${levelColor(p.shortPct)}5h:${p.shortPct}%${FG_OFF} \x1b[2m↻${p.shortResetRel}\x1b[22m`);
|
|
533
|
+
}
|
|
534
|
+
if (p.weeklyPct !== undefined) {
|
|
535
|
+
parts.push(p.weeklyResetRel === undefined
|
|
536
|
+
? `${levelColor(p.weeklyPct)}wk:${p.weeklyPct}%${FG_OFF}`
|
|
537
|
+
: `${levelColor(p.weeklyPct)}wk:${p.weeklyPct}%${FG_OFF} \x1b[2m↻${p.weeklyResetRel}\x1b[22m`);
|
|
538
|
+
}
|
|
539
|
+
return parts.join(" ");
|
|
474
540
|
}
|
|
475
541
|
/**
|
|
476
542
|
* Build claude argv (C8). `--model` is NOT passed here — for the claude
|
|
@@ -1189,9 +1255,9 @@ export function parseCliArgs(argv) {
|
|
|
1189
1255
|
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
1190
1256
|
if (values.timeout !== undefined) {
|
|
1191
1257
|
const n = Number(values.timeout);
|
|
1192
|
-
if (!Number.isFinite(n) || n < 1000
|
|
1258
|
+
if (!Number.isFinite(n) || n < 1000) {
|
|
1193
1259
|
return {
|
|
1194
|
-
error: `--timeout must be a number
|
|
1260
|
+
error: `--timeout must be a number >= 1000, got "${values.timeout}"`,
|
|
1195
1261
|
};
|
|
1196
1262
|
}
|
|
1197
1263
|
timeoutMs = n;
|
|
@@ -1255,9 +1321,9 @@ export function parseApiArgs(argv) {
|
|
|
1255
1321
|
let maxTokens = API_DEFAULT_MAX_TOKENS;
|
|
1256
1322
|
if (values["max-tokens"] !== undefined) {
|
|
1257
1323
|
const n = Number(values["max-tokens"]);
|
|
1258
|
-
if (!Number.isFinite(n) || n < 1
|
|
1324
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
1259
1325
|
return {
|
|
1260
|
-
error: `--max-tokens must be a number
|
|
1326
|
+
error: `--max-tokens must be a finite number >= 1, got "${values["max-tokens"]}"`,
|
|
1261
1327
|
};
|
|
1262
1328
|
}
|
|
1263
1329
|
maxTokens = n;
|
|
@@ -1265,9 +1331,9 @@ export function parseApiArgs(argv) {
|
|
|
1265
1331
|
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
1266
1332
|
if (values.timeout !== undefined) {
|
|
1267
1333
|
const n = Number(values.timeout);
|
|
1268
|
-
if (!Number.isFinite(n) || n < 1000
|
|
1334
|
+
if (!Number.isFinite(n) || n < 1000) {
|
|
1269
1335
|
return {
|
|
1270
|
-
error: `--timeout must be a number
|
|
1336
|
+
error: `--timeout must be a number >= 1000, got "${values.timeout}"`,
|
|
1271
1337
|
};
|
|
1272
1338
|
}
|
|
1273
1339
|
timeoutMs = n;
|
|
@@ -1294,9 +1360,9 @@ export function parseApiArgs(argv) {
|
|
|
1294
1360
|
let retries = 1;
|
|
1295
1361
|
if (values.retry !== undefined) {
|
|
1296
1362
|
const n = Number(values.retry);
|
|
1297
|
-
if (!Number.isInteger(n) || n < 0
|
|
1363
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1298
1364
|
return {
|
|
1299
|
-
error: `--retry must be
|
|
1365
|
+
error: `--retry must be a non-negative integer, got "${values.retry}"`,
|
|
1300
1366
|
};
|
|
1301
1367
|
}
|
|
1302
1368
|
retries = n;
|
|
@@ -1408,8 +1474,8 @@ export function runClaude(args, timeoutMs, cwd) {
|
|
|
1408
1474
|
/**
|
|
1409
1475
|
* Spawn a backend with stdio fully inherited (interactive TUI mode).
|
|
1410
1476
|
*
|
|
1411
|
-
* Unlike runAgy/runClaude: no timeout, no stdout/stderr capture
|
|
1412
|
-
*
|
|
1477
|
+
* Unlike runAgy/runClaude: no timeout, no stdout/stderr capture —
|
|
1478
|
+
* the child owns the terminal. The child's exit code is passed
|
|
1413
1479
|
* through unchanged (SIGINT→130, SIGTERM→143 per shell convention).
|
|
1414
1480
|
*/
|
|
1415
1481
|
function spawnInteractive(bin, args, cwd) {
|
|
@@ -1466,7 +1532,7 @@ const API_RETRY_MAX_DELAY_MS = 4000;
|
|
|
1466
1532
|
* (timeoutMs). Either firing aborts the fetch via AbortController → exit 1
|
|
1467
1533
|
* with a timeout message.
|
|
1468
1534
|
* - stream=false: awaits the full JSON body and extracts `content[].text`,
|
|
1469
|
-
*
|
|
1535
|
+
* returned as-is (no size cap — endpoint limits are the endpoint's call).
|
|
1470
1536
|
*
|
|
1471
1537
|
* TRANSIENT failures are retried automatically (default 1 retry, `--retry N`):
|
|
1472
1538
|
* network-level fetch errors, HTTP 408/429/5xx, malformed JSON, empty-content
|
|
@@ -1639,7 +1705,7 @@ async function runApiAttempt(req) {
|
|
|
1639
1705
|
};
|
|
1640
1706
|
}
|
|
1641
1707
|
return {
|
|
1642
|
-
outcome: { exitCode: 0, stdout:
|
|
1708
|
+
outcome: { exitCode: 0, stdout: text, stderr: "" },
|
|
1643
1709
|
retryable: false,
|
|
1644
1710
|
};
|
|
1645
1711
|
}
|
|
@@ -1751,8 +1817,6 @@ async function runApiAttempt(req) {
|
|
|
1751
1817
|
retryable: false,
|
|
1752
1818
|
};
|
|
1753
1819
|
}
|
|
1754
|
-
// Streaming output is not truncated (real-time, contract §A); only the
|
|
1755
|
-
// non-stream path truncates.
|
|
1756
1820
|
return {
|
|
1757
1821
|
outcome: { exitCode: 0, stdout: aggregated, stderr: "" },
|
|
1758
1822
|
retryable: false,
|
|
@@ -1878,17 +1942,102 @@ function readStdin() {
|
|
|
1878
1942
|
// ---------------------------------------------------------------------------
|
|
1879
1943
|
// Provider picker (TTY only) — arrow keys, zero deps
|
|
1880
1944
|
// ---------------------------------------------------------------------------
|
|
1945
|
+
// Picker frame colors: 16-color structure + truecolor Sage accents (B2).
|
|
1946
|
+
const DIM_ON = "\x1b[2m";
|
|
1947
|
+
const INTENSITY_OFF = "\x1b[22m";
|
|
1948
|
+
const CYAN = "\x1b[36m";
|
|
1949
|
+
const BOLD_ON = "\x1b[1m";
|
|
1950
|
+
const RESET_ALL = "\x1b[0m";
|
|
1951
|
+
/** 选中行背景 #292e42(truecolor)。 */
|
|
1952
|
+
const SELECTED_BG = "\x1b[48;2;41;46;66m";
|
|
1953
|
+
const PICKER_TITLE_MAIN = "◆ gcli";
|
|
1954
|
+
const PICKER_TITLE_SUB = " · 选择 provider";
|
|
1955
|
+
const PICKER_HINT_KEYS = "↑↓/j/k 移动 · Enter 确认 · Esc 退出";
|
|
1956
|
+
const PICKER_HINT_SKIP = "Esc 退出";
|
|
1957
|
+
const PICKER_SEPARATOR = "─".repeat(50);
|
|
1958
|
+
const NO_QUOTA_MARK = "—";
|
|
1959
|
+
/**
|
|
1960
|
+
* 给 picker 实际持有的 quota 文本(`formatQuota` 输出格式,byte-locked)上
|
|
1961
|
+
* 色:`5h:P%`/`wk:P%` token 按各自 pct 染色,`↻rel` token 恒 dim。与
|
|
1962
|
+
* `colorQuota` 同一视觉,只是入参是已格式化文本(DI 契约里 entries.quota
|
|
1963
|
+
* 为 string,渲染层拿不到结构化窗口)。前提:pct 为整数——生产 parsers
|
|
1964
|
+
* 均 Math.floor 保证;异形 token 优雅退化为不染色。
|
|
1965
|
+
*/
|
|
1966
|
+
function colorQuotaText(text) {
|
|
1967
|
+
return text
|
|
1968
|
+
.split(" ")
|
|
1969
|
+
.map((token) => {
|
|
1970
|
+
const win = /^(5h|wk):(\d+)%$/.exec(token);
|
|
1971
|
+
if (win !== null) {
|
|
1972
|
+
return `${levelColor(Number(win[2]))}${token}${FG_OFF}`;
|
|
1973
|
+
}
|
|
1974
|
+
if (token.startsWith("↻"))
|
|
1975
|
+
return `${DIM_ON}${token}${INTENSITY_OFF}`;
|
|
1976
|
+
return token;
|
|
1977
|
+
})
|
|
1978
|
+
.join(" ");
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* One entry row: `❯ `/two-space prefix | name column (max name width + 2,
|
|
1982
|
+
* left-aligned) | colored quota (or dim — placeholder) | cyan `●<tag>`.
|
|
1983
|
+
* Selected rows add the truecolor background + a bold name and end with a
|
|
1984
|
+
* full reset; unselected rows keep the same structure without background.
|
|
1985
|
+
*/
|
|
1986
|
+
function pickerEntryRow(entry, selected, nameWidth, noColor) {
|
|
1987
|
+
const name = entry.name.padEnd(nameWidth);
|
|
1988
|
+
const quotaText = entry.quota !== undefined && entry.quota !== "" ? entry.quota : undefined;
|
|
1989
|
+
const quota = quotaText === undefined
|
|
1990
|
+
? noColor
|
|
1991
|
+
? NO_QUOTA_MARK
|
|
1992
|
+
: `${DIM_ON}${NO_QUOTA_MARK}${INTENSITY_OFF}`
|
|
1993
|
+
: noColor
|
|
1994
|
+
? quotaText
|
|
1995
|
+
: colorQuotaText(quotaText);
|
|
1996
|
+
const tag = entry.tag !== undefined && entry.tag !== ""
|
|
1997
|
+
? noColor
|
|
1998
|
+
? ` ●${entry.tag}`
|
|
1999
|
+
: ` ${CYAN}●${entry.tag}${FG_OFF}`
|
|
2000
|
+
: "";
|
|
2001
|
+
if (noColor) {
|
|
2002
|
+
return `${selected ? "❯ " : " "}${name}${quota}${tag}`;
|
|
2003
|
+
}
|
|
2004
|
+
return selected
|
|
2005
|
+
? `${SELECTED_BG}${COLOR_SAGE}❯${FG_OFF} ${BOLD_ON}${name}${INTENSITY_OFF}${quota}${tag}${RESET_ALL}`
|
|
2006
|
+
: ` ${name}${quota}${tag}`;
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* The full picker frame as plain lines: 标题 2 行 + dim 分隔线 + 条目 N 行 +
|
|
2010
|
+
* dim 末行提示 (`Esc 退出`, non-selectable — skip 只经 Esc/C-g(skip = 退出不启动)). Pure:
|
|
2011
|
+
* no I/O; with `noColor` (NO_COLOR downgrade) zero ANSI escapes — 仅纯文本
|
|
2012
|
+
* 排版,❯ 缩进 + 列对齐保留。Redraw cursor-up height = `entries.length + 4`.
|
|
2013
|
+
*/
|
|
2014
|
+
export function renderPickerRows(entries, selectedIndex, noColor) {
|
|
2015
|
+
const nameWidth = entries.reduce((w, e) => Math.max(w, e.name.length), 0) + 2;
|
|
2016
|
+
const rows = [
|
|
2017
|
+
noColor
|
|
2018
|
+
? `${PICKER_TITLE_MAIN}${PICKER_TITLE_SUB}`
|
|
2019
|
+
: `${CYAN}${BOLD_ON}${PICKER_TITLE_MAIN}${RESET_ALL}${PICKER_TITLE_SUB}`,
|
|
2020
|
+
noColor ? PICKER_HINT_KEYS : `${DIM_ON}${PICKER_HINT_KEYS}${INTENSITY_OFF}`,
|
|
2021
|
+
noColor ? PICKER_SEPARATOR : `${DIM_ON}${PICKER_SEPARATOR}${INTENSITY_OFF}`,
|
|
2022
|
+
];
|
|
2023
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2024
|
+
rows.push(pickerEntryRow(entries[i], i === selectedIndex, nameWidth, noColor));
|
|
2025
|
+
}
|
|
2026
|
+
rows.push(noColor ? PICKER_HINT_SKIP : `${DIM_ON}${PICKER_HINT_SKIP}${INTENSITY_OFF}`);
|
|
2027
|
+
return rows;
|
|
2028
|
+
}
|
|
1881
2029
|
/**
|
|
1882
2030
|
* Production deps.pickProvider: arrow-key menu rendered entirely on stderr
|
|
1883
2031
|
* (stdout stays pipe-clean). ↑↓/j/k move with wrap, Enter confirms, Esc
|
|
1884
2032
|
* skips, ctrl-c restores the terminal then exits 130; any other key is a
|
|
1885
|
-
* noop (C-P1).
|
|
2033
|
+
* noop (C-P1). Frame = 标题 2 行 + 分隔线 + 条目 N 行 + dim `Esc 退出`
|
|
2034
|
+
* 末行提示——旧的可选中「不切换」行已退化为提示(skip 走 Esc/C-g),移动
|
|
2035
|
+
* 只覆盖条目行。
|
|
1886
2036
|
*
|
|
1887
2037
|
* Zero deps: `readline.emitKeypressEvents` + raw-mode stdin + hand-written
|
|
1888
|
-
* ANSI
|
|
1889
|
-
*
|
|
1890
|
-
*
|
|
1891
|
-
* pressing Esc) resolves {kind:"skip"}.
|
|
2038
|
+
* ANSI (16-color structure + truecolor Sage Limit 染色; `NO_COLOR` 非空 →
|
|
2039
|
+
* 全无色纯文本). Rows are redrawn in place (cursor-up + `\r` + clear-to-EOL
|
|
2040
|
+
* per line) so navigation leaves no ghosting (C-P3).
|
|
1892
2041
|
*
|
|
1893
2042
|
* Raw-mode lifecycle: `process.stdin.isRaw` is saved before
|
|
1894
2043
|
* `setRawMode(true)` and restored on EVERY exit path (confirm / Esc / ctrl-c
|
|
@@ -1897,50 +2046,64 @@ function readStdin() {
|
|
|
1897
2046
|
* spawned claude TUI takes stdin over cleanly. The picker always completes
|
|
1898
2047
|
* before any backend spawn.
|
|
1899
2048
|
*/
|
|
1900
|
-
function pickProviderInteractive(entries, initialIndex) {
|
|
2049
|
+
export function pickProviderInteractive(entries, initialIndex) {
|
|
1901
2050
|
return new Promise((resolvePromise) => {
|
|
1902
2051
|
const stderr = process.stderr;
|
|
1903
2052
|
const stdin = process.stdin;
|
|
1904
|
-
const
|
|
1905
|
-
|
|
2053
|
+
const noColor = (process.env.NO_COLOR ?? "") !== "";
|
|
2054
|
+
const frameRows = entries.length + 4; // 标题2 + 分隔线1 + 条目N + 末行1
|
|
2055
|
+
let index = Math.min(Math.max(Math.trunc(initialIndex), 0), Math.max(entries.length - 1, 0)); // clamp (D4)
|
|
1906
2056
|
let settled = false;
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
const
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
const entry = entries[row];
|
|
1913
|
-
return entry.quota ? `${entry.name} ${entry.quota}` : entry.name;
|
|
1914
|
-
};
|
|
1915
|
-
// Selected row: ❯ + full-line inverse video; unselected: two-space indent
|
|
1916
|
-
// (C-P7). \x1b[K clears to EOL so a shorter previous render leaves no
|
|
1917
|
-
// ghosting (残影).
|
|
1918
|
-
const rowText = (row) => {
|
|
1919
|
-
const label = labelOf(row);
|
|
1920
|
-
return row === index
|
|
1921
|
-
? `\x1b[7m❯ ${label}\x1b[27m\x1b[K`
|
|
1922
|
-
: ` ${label}\x1b[K`;
|
|
1923
|
-
};
|
|
1924
|
-
const drawRows = () => {
|
|
1925
|
-
for (let row = 0; row < rowCount; row++) {
|
|
1926
|
-
stderr.write(`${rowText(row)}\n`);
|
|
2057
|
+
// Full repaint: pure row construction + per-line clear-to-EOL so a
|
|
2058
|
+
// shorter previous render leaves no ghosting (残影).
|
|
2059
|
+
const drawFrame = () => {
|
|
2060
|
+
for (const line of renderPickerRows(entries, index, noColor)) {
|
|
2061
|
+
stderr.write(`${line}\x1b[K\n`);
|
|
1927
2062
|
}
|
|
1928
2063
|
};
|
|
1929
2064
|
// In-place redraw: the cursor sits just below the last row after each
|
|
1930
|
-
// draw, so move it back up over every
|
|
2065
|
+
// draw, so move it back up over every frame line before repainting.
|
|
1931
2066
|
const redraw = () => {
|
|
1932
|
-
stderr.write(`\x1b[${
|
|
1933
|
-
|
|
2067
|
+
stderr.write(`\x1b[${frameRows}A\r`);
|
|
2068
|
+
drawFrame();
|
|
1934
2069
|
};
|
|
1935
2070
|
// Raw-mode lifecycle (C-P3): save → raw → ... EVERY exit path restores.
|
|
1936
2071
|
const wasRaw = stdin.isRaw === true;
|
|
1937
2072
|
const dataListenersBefore = stdin.listeners("data");
|
|
1938
2073
|
let onKeypress;
|
|
1939
2074
|
let onGone = () => { };
|
|
2075
|
+
// 孤立 ESC 快速路径:emitKeypressEvents 不带 interface 参数时没有 escape
|
|
2076
|
+
// 消歧超时,真实终端按 Esc 发出的孤立 \x1b 会被状态机无限挂起(keypress
|
|
2077
|
+
// 永不发出)——实测 PTY 下 30s 无事件。因此监听 data 层:单字节 \x1b 且
|
|
2078
|
+
// 50ms 内无后续字节 → 视为 Esc 键 skip;有后续(箭头序列拆包等)→ 取消
|
|
2079
|
+
// 计时器交还 readline 正常解析。readline 的 name==="escape" 路径保留,
|
|
2080
|
+
// settled 守卫防双触发。
|
|
2081
|
+
let escTimer;
|
|
2082
|
+
const onDataFastEsc = (chunk) => {
|
|
2083
|
+
const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
2084
|
+
if (s !== "\x1b") {
|
|
2085
|
+
if (escTimer !== undefined) {
|
|
2086
|
+
clearTimeout(escTimer);
|
|
2087
|
+
escTimer = undefined;
|
|
2088
|
+
}
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
if (escTimer !== undefined)
|
|
2092
|
+
clearTimeout(escTimer);
|
|
2093
|
+
escTimer = setTimeout(() => {
|
|
2094
|
+
escTimer = undefined;
|
|
2095
|
+
finish({ kind: "skip" });
|
|
2096
|
+
}, 50);
|
|
2097
|
+
};
|
|
1940
2098
|
const cleanup = () => {
|
|
1941
2099
|
if (onKeypress !== undefined) {
|
|
1942
2100
|
stdin.removeListener("keypress", onKeypress);
|
|
1943
2101
|
}
|
|
2102
|
+
if (escTimer !== undefined) {
|
|
2103
|
+
clearTimeout(escTimer);
|
|
2104
|
+
escTimer = undefined;
|
|
2105
|
+
}
|
|
2106
|
+
stdin.removeListener("data", onDataFastEsc);
|
|
1944
2107
|
stdin.removeListener("close", onGone);
|
|
1945
2108
|
stdin.removeListener("error", onGone);
|
|
1946
2109
|
// emitKeypressEvents lazily attaches an internal 'data' listener (via
|
|
@@ -1963,9 +2126,8 @@ function pickProviderInteractive(entries, initialIndex) {
|
|
|
1963
2126
|
cleanup();
|
|
1964
2127
|
resolvePromise(outcome);
|
|
1965
2128
|
};
|
|
1966
|
-
// Title + hint drawn once; entry rows below it (C-P7).
|
|
1967
|
-
|
|
1968
|
-
drawRows();
|
|
2129
|
+
// Title + hint drawn once; entry rows below it get repainted (C-P7).
|
|
2130
|
+
drawFrame();
|
|
1969
2131
|
emitKeypressEvents(stdin);
|
|
1970
2132
|
onKeypress = (_str, key) => {
|
|
1971
2133
|
try {
|
|
@@ -1976,13 +2138,15 @@ function pickProviderInteractive(entries, initialIndex) {
|
|
|
1976
2138
|
}
|
|
1977
2139
|
if (key === undefined)
|
|
1978
2140
|
return;
|
|
1979
|
-
|
|
2141
|
+
// Movement covers entry rows only (the 末行提示 is not selectable).
|
|
2142
|
+
const action = applyPickerKey(key, index, entries.length);
|
|
1980
2143
|
if (action.type === "move") {
|
|
1981
2144
|
index = action.index;
|
|
1982
2145
|
redraw();
|
|
1983
2146
|
}
|
|
1984
2147
|
else if (action.type === "confirm") {
|
|
1985
|
-
//
|
|
2148
|
+
// index is always an entry row; the guard only matters for the
|
|
2149
|
+
// empty-list edge (nothing selectable → skip).
|
|
1986
2150
|
finish(index < entries.length
|
|
1987
2151
|
? { kind: "select", entry: entries[index] }
|
|
1988
2152
|
: { kind: "skip" });
|
|
@@ -2002,6 +2166,7 @@ function pickProviderInteractive(entries, initialIndex) {
|
|
|
2002
2166
|
// skip rather than hang.
|
|
2003
2167
|
onGone = () => finish({ kind: "skip" });
|
|
2004
2168
|
stdin.on("keypress", onKeypress);
|
|
2169
|
+
stdin.on("data", onDataFastEsc);
|
|
2005
2170
|
stdin.once("close", onGone);
|
|
2006
2171
|
stdin.once("error", onGone);
|
|
2007
2172
|
if (stdin.isTTY)
|
|
@@ -2283,11 +2448,11 @@ const HELP = `Usage:
|
|
|
2283
2448
|
gcli hermes <provider|status|rollback> [options] hermes agent 主模型一键切换
|
|
2284
2449
|
|
|
2285
2450
|
gcli sits in front of three backends and gives skills a stable entry point:
|
|
2286
|
-
|
|
2287
|
-
|
|
2451
|
+
a hard timeout, explicit exit codes, and stdin piping (\`-p -\`). Output is
|
|
2452
|
+
passed through unmodified — size limits are the endpoint's business.
|
|
2288
2453
|
|
|
2289
2454
|
Without -p, the agy/claude backends launch their interactive TUI (inherited
|
|
2290
|
-
stdio; no timeout
|
|
2455
|
+
stdio; no timeout; the child's exit code is passed through).
|
|
2291
2456
|
This requires a TTY — piping into gcli without -p is an error (use '-p -' to
|
|
2292
2457
|
pipe a prompt). The api backend is one-shot HTTP and always requires -p.
|
|
2293
2458
|
|
|
@@ -2302,7 +2467,7 @@ agy backend (\`gcli agy ...\` — the subcommand is REQUIRED; bare \`gcli\` is c
|
|
|
2302
2467
|
--yolo Auto-approve tool actions (agy --dangerously-skip-permissions)
|
|
2303
2468
|
--sandbox Run agy in sandbox mode
|
|
2304
2469
|
--cwd <dir> Working directory (added via agy --add-dir)
|
|
2305
|
-
--timeout <ms> Hard timeout in ms (default 300000
|
|
2470
|
+
--timeout <ms> Hard timeout in ms (default 300000)
|
|
2306
2471
|
--version Print the agy version
|
|
2307
2472
|
--help Show this help
|
|
2308
2473
|
-- <args...> Pass remaining args through to agy verbatim
|
|
@@ -2313,7 +2478,7 @@ claude backend (default; bare \`gcli ...\` === \`gcli claude ...\`):
|
|
|
2313
2478
|
--pick Force the provider picker menu, even in print mode
|
|
2314
2479
|
--model <name> Override ANTHROPIC_MODEL in the provider env
|
|
2315
2480
|
--cwd <dir> Working directory (added via claude --add-dir)
|
|
2316
|
-
--timeout <ms> Hard timeout in ms (default 300000
|
|
2481
|
+
--timeout <ms> Hard timeout in ms (default 300000)
|
|
2317
2482
|
--version Print the claude version
|
|
2318
2483
|
--help Show this help
|
|
2319
2484
|
-- <args...> Pass remaining args through to claude verbatim
|
|
@@ -2323,8 +2488,8 @@ claude backend (default; bare \`gcli ...\` === \`gcli claude ...\`):
|
|
|
2323
2488
|
does NOT rewrite ~/.claude/settings.json.
|
|
2324
2489
|
- No --provider in a TTY: gcli lists all cc-switch providers (in
|
|
2325
2490
|
cc-switch's own DB order) in an arrow-key picker (↑↓/j/k and Emacs
|
|
2326
|
-
C-n/C-p move · Enter 确认 · Esc/C-g =
|
|
2327
|
-
|
|
2491
|
+
C-n/C-p move · Enter 确认 · Esc/C-g = 退出(不启动 claude);
|
|
2492
|
+
M-</M-> jump to first/last; ctrl-c exits 130). In print mode
|
|
2328
2493
|
(-p) the last confirmed provider is reused silently (one stderr hint
|
|
2329
2494
|
line); the menu only pops when nothing valid is remembered, or when
|
|
2330
2495
|
--pick is given. Without a TTY the picker never triggers — no prompt,
|
|
@@ -2346,8 +2511,9 @@ api backend (\`gcli api ...\`) — pure HTTP, no agent, no subprocess:
|
|
|
2346
2511
|
-p, --prompt <text|-> Prompt text, "-" for stdin (REQUIRED)
|
|
2347
2512
|
--provider <name> cc-switch provider (REQUIRED; supplies base URL/token/model)
|
|
2348
2513
|
--model <name> Override the provider's ANTHROPIC_MODEL
|
|
2349
|
-
--max-tokens <n> Output token budget (default 80000
|
|
2350
|
-
|
|
2514
|
+
--max-tokens <n> Output token budget (default 80000; upper bound is
|
|
2515
|
+
whatever the endpoint accepts)
|
|
2516
|
+
--timeout <ms> Idle + absolute timeout in ms (default 300000)
|
|
2351
2517
|
--stream|--no-stream Stream SSE and aggregate live (default stream)
|
|
2352
2518
|
--thinking <mode> auto|off|on (default auto = omit the thinking field,
|
|
2353
2519
|
endpoint default applies; off sends disabled; on
|
|
@@ -2355,7 +2521,7 @@ api backend (\`gcli api ...\`) — pure HTTP, no agent, no subprocess:
|
|
|
2355
2521
|
--max-tokens >= 2048)
|
|
2356
2522
|
--retry <n> Auto retries for transient failures: network errors,
|
|
2357
2523
|
HTTP 408/429/5xx, malformed JSON, empty content
|
|
2358
|
-
(default 1
|
|
2524
|
+
(default 1; exponential backoff 400ms..4s)
|
|
2359
2525
|
--version Print the api backend identity
|
|
2360
2526
|
--help Show this help
|
|
2361
2527
|
|
|
@@ -2415,12 +2581,12 @@ function mapSpawnResult(result, backend, timeoutMs) {
|
|
|
2415
2581
|
return {
|
|
2416
2582
|
exitCode: 1,
|
|
2417
2583
|
stdout: "",
|
|
2418
|
-
stderr: `gcli: ${backend} failed (exit ${result.exitCode})\n${
|
|
2584
|
+
stderr: `gcli: ${backend} failed (exit ${result.exitCode})\n${errInfo}`,
|
|
2419
2585
|
};
|
|
2420
2586
|
}
|
|
2421
2587
|
if (!out) {
|
|
2422
2588
|
const info = result.stderr.trim()
|
|
2423
|
-
? `stderr: ${
|
|
2589
|
+
? `stderr: ${result.stderr}`
|
|
2424
2590
|
: "no output";
|
|
2425
2591
|
return {
|
|
2426
2592
|
exitCode: 1,
|
|
@@ -2428,7 +2594,7 @@ function mapSpawnResult(result, backend, timeoutMs) {
|
|
|
2428
2594
|
stderr: `gcli: ${backend} returned ${info}`,
|
|
2429
2595
|
};
|
|
2430
2596
|
}
|
|
2431
|
-
return { exitCode: 0, stdout:
|
|
2597
|
+
return { exitCode: 0, stdout: out, stderr: "" };
|
|
2432
2598
|
}
|
|
2433
2599
|
async function runAgyBackend(parsed, deps) {
|
|
2434
2600
|
// --pick is claude-backend-only (cc-switch provider picker); agy has no
|
|
@@ -2573,6 +2739,7 @@ async function runClaudeBackend(parsed, deps) {
|
|
|
2573
2739
|
}
|
|
2574
2740
|
}
|
|
2575
2741
|
let settingsEnv;
|
|
2742
|
+
let pickerSkipped = false;
|
|
2576
2743
|
let pickerWarning;
|
|
2577
2744
|
if (parsed.provider !== undefined) {
|
|
2578
2745
|
const validated = validateProviderName(parsed.provider);
|
|
@@ -2675,13 +2842,17 @@ async function runClaudeBackend(parsed, deps) {
|
|
|
2675
2842
|
}
|
|
2676
2843
|
const quotaMap = await deps.fetchProviderQuotas(quotaItems);
|
|
2677
2844
|
// D5/D6 (revise-3): rows are name + quota subtitle (host is gone);
|
|
2678
|
-
// the remembered row
|
|
2845
|
+
// the remembered row carries its ●上次 marker as a structured tag
|
|
2846
|
+
// (rendering decides placement/color; quota text stays byte-clean).
|
|
2679
2847
|
const entries = ordered.map((p) => {
|
|
2680
|
-
|
|
2848
|
+
const entry = {
|
|
2849
|
+
name: p.name,
|
|
2850
|
+
quota: quotaMap.get(p.name),
|
|
2851
|
+
};
|
|
2681
2852
|
if (remembered !== undefined && p.name === remembered) {
|
|
2682
|
-
|
|
2853
|
+
entry.tag = "上次";
|
|
2683
2854
|
}
|
|
2684
|
-
return
|
|
2855
|
+
return entry;
|
|
2685
2856
|
});
|
|
2686
2857
|
const picked = await deps.pickProvider(entries, memoryValid ? memoryIndex : 0);
|
|
2687
2858
|
if (picked.kind === "select") {
|
|
@@ -2701,7 +2872,10 @@ async function runClaudeBackend(parsed, deps) {
|
|
|
2701
2872
|
await deps.writeLastProvider(picked.entry.name);
|
|
2702
2873
|
}
|
|
2703
2874
|
}
|
|
2704
|
-
|
|
2875
|
+
else {
|
|
2876
|
+
pickerSkipped = true; // Esc/C-g → 退出,不启动 claude
|
|
2877
|
+
}
|
|
2878
|
+
// skip 语义(2026-09 用户裁定):退出。memory untouched.
|
|
2705
2879
|
}
|
|
2706
2880
|
}
|
|
2707
2881
|
}
|
|
@@ -2720,6 +2894,15 @@ async function runClaudeBackend(parsed, deps) {
|
|
|
2720
2894
|
...o,
|
|
2721
2895
|
stderr: o.stderr ? `${pickerWarning}\n${o.stderr}` : pickerWarning,
|
|
2722
2896
|
};
|
|
2897
|
+
// Esc/C-g skip = 退出(2026-09 用户裁定,替代旧「不切换照常启动」语义):
|
|
2898
|
+
// 不 spawn 任何后端,干净退出 exit 0。stderr 提示确认动作已被感知。
|
|
2899
|
+
if (pickerSkipped) {
|
|
2900
|
+
return withPickerWarning({
|
|
2901
|
+
exitCode: 0,
|
|
2902
|
+
stdout: "",
|
|
2903
|
+
stderr: "gcli: 已退出(未启动 claude)",
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2723
2906
|
const cwdAbs = parsed.cwd ? resolve(parsed.cwd) : undefined;
|
|
2724
2907
|
// Interactive mode (no -p in a TTY): inherit stdio, pass exit code through.
|
|
2725
2908
|
if (prompt === undefined) {
|