gsb-cli 0.1.2

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/README.md ADDED
@@ -0,0 +1,431 @@
1
+ # gsb-cli
2
+
3
+ > 在本地命令行控制远端 GSB 评估平台:检查数据、上传版本、创建任务、发布评估、导出结果。
4
+
5
+ ![Node](https://img.shields.io/badge/node-%3E%3D20.11-339933)
6
+ ![npm](https://img.shields.io/badge/install-npm-blue)
7
+ ![License](https://img.shields.io/badge/license-MIT-lightgrey)
8
+
9
+ `gsb-cli` 是 ChatBuy GSB 评估平台的命令行客户端。它只负责通过 HTTP API 操作平台服务,不包含平台后端,也不要求使用者 clone 平台仓库。
10
+
11
+ 适合:
12
+
13
+ - PM / 评估负责人快速创建 GSB 人工评估任务;
14
+ - Agent 自动化上传数据、发布任务、导出结果;
15
+ - 多人共用同一个远端 GSB 平台,但每个人只在本机安装一个轻量 CLI。
16
+
17
+ ## 为什么需要 gsb-cli
18
+
19
+ 过去要做一次 GSB 任务,常见流程是打开网页、手动上传、手动绑定、手动检查发布状态。这个过程对人还可以,对 Agent 和批量任务不稳定。
20
+
21
+ `gsb-cli` 把这些动作变成可复制的命令:
22
+
23
+ | 能力 | 说明 |
24
+ | --- | --- |
25
+ | 数据检查 | 在上传前检查 A/B 文件是否一一对应、JSON 是否有效、默认 renderer 是否可能展示为空 |
26
+ | 数据上传 | 把本地版本目录上传到远端 GSB 平台 |
27
+ | 任务管理 | 创建任务、绑定数据集、配置分配策略、发布前检查、发布任务 |
28
+ | Renderer 管理 | 上传或清除任务级 `renderer.js` |
29
+ | 结果回收 | 查询全员汇总,导出 JSON / CSV / ZIP 结果 |
30
+ | Agent 友好 | 所有核心命令支持 `--json`,失败时返回可修复的结构化问题 |
31
+
32
+ ## 工作方式
33
+
34
+ ```text
35
+ 你的电脑
36
+ └─ gsb-cli
37
+ ├─ 读取本地数据目录
38
+ ├─ 上传 JSON 文件 / renderer.js
39
+ └─ 通过 HTTP API 控制远端平台
40
+
41
+ 远端 GSB 平台
42
+ ├─ 保存数据集
43
+ ├─ 创建和发布评估任务
44
+ └─ 导出评估结果
45
+ ```
46
+
47
+ 关键点:
48
+
49
+ - `gsb-cli` 在本地运行。
50
+ - `--base-url` 决定要控制哪一个 GSB 平台。
51
+ - 远端平台不能读取你电脑上的 `/Users/...` 路径。
52
+ - 远端使用时,先 `dataset upload`,再用返回的 dataset id 做 `task bind`。
53
+
54
+ ## 30 秒安装
55
+
56
+ 要求:
57
+
58
+ - Node.js `>= 20.11`
59
+ - npm `>= 10`
60
+
61
+ 检查版本:
62
+
63
+ ```bash
64
+ node -v
65
+ npm -v
66
+ ```
67
+
68
+ 从 npm 安装:
69
+
70
+ ```bash
71
+ npm install -g gsb-cli
72
+ ```
73
+
74
+ 安装包内自带 `gsb-eval` Agent skill。`npm install` 会默认把 skill 复制到 Codex 和 Cursor 的 skills 目录:
75
+
76
+ - Codex: `~/.codex/skills/gsb-eval`
77
+ - Cursor: `~/.cursor/skills/gsb-eval`
78
+
79
+ 如果只想安装到某一个 Agent,或跳过自动安装:
80
+
81
+ ```bash
82
+ GSB_CLI_SKILL_TARGET=codex npm install -g gsb-cli
83
+ GSB_CLI_SKIP_SKILL_INSTALL=1 npm install -g gsb-cli
84
+ ```
85
+
86
+ 验证:
87
+
88
+ ```bash
89
+ gsb-cli --version
90
+ gsb-cli --help
91
+ gsb-cli skill status --target all
92
+ ```
93
+
94
+ 一次性运行,不全局安装:
95
+
96
+ ```bash
97
+ npx --yes gsb-cli --help
98
+ ```
99
+
100
+ 更新:
101
+
102
+ ```bash
103
+ npm install -g gsb-cli@latest
104
+ ```
105
+
106
+ 如果需要从 GitHub main 分支安装开发版:
107
+
108
+ ```bash
109
+ npm install -g https://github.com/GasonW/gsb-cli/archive/refs/heads/main.tar.gz
110
+ ```
111
+
112
+ CLI 会定期检查最新版本并在非 JSON 输出里提示。手动检查:
113
+
114
+ ```bash
115
+ gsb-cli version --check
116
+ ```
117
+
118
+ 检查结果默认缓存 24 小时,可用 `GSB_CLI_NO_UPDATE_CHECK=1` 关闭提醒。JSON 输出不会带版本提醒,方便 Agent 稳定解析。
119
+
120
+ 卸载:
121
+
122
+ ```bash
123
+ npm uninstall -g gsb-cli
124
+ ```
125
+
126
+ ## 2 分钟完成一个 GSB 任务
127
+
128
+ 先指定平台地址:
129
+
130
+ ```bash
131
+ export GSB_BASE_URL="https://<gsb-platform-url>"
132
+ ```
133
+
134
+ 检查平台是否可访问:
135
+
136
+ ```bash
137
+ gsb-cli doctor
138
+ ```
139
+
140
+ 登录:
141
+
142
+ ```bash
143
+ gsb-cli auth login \
144
+ --username <user> \
145
+ --password <password> \
146
+ --json
147
+ ```
148
+
149
+ 如果用户确认没有账号,可以注册并自动保存 session。不要在登录失败后替用户静默注册新账号:
150
+
151
+ ```bash
152
+ gsb-cli auth register \
153
+ --username <user> \
154
+ --password <password> \
155
+ --json
156
+ ```
157
+
158
+ 如果不想把密码写进命令历史:
159
+
160
+ ```bash
161
+ export GSB_PASSWORD="<password>"
162
+ gsb-cli auth login --username <user> --json
163
+ ```
164
+
165
+ 准备两个版本目录:
166
+
167
+ ```text
168
+ baseline/
169
+ item_0001.json
170
+ item_0002.json
171
+
172
+ candidate/
173
+ item_0001.json
174
+ item_0002.json
175
+ ```
176
+
177
+ 检查并上传:
178
+
179
+ ```bash
180
+ gsb-cli dataset check --a ./baseline --b ./candidate --json
181
+ gsb-cli dataset upload --a ./baseline --b ./candidate --json
182
+ ```
183
+
184
+ 创建任务:
185
+
186
+ ```bash
187
+ gsb-cli task create \
188
+ --name "candidate vs baseline" \
189
+ --purpose "评估 candidate 相比 baseline 的回答质量和上线风险" \
190
+ --json
191
+ ```
192
+
193
+ `--purpose` 是给任务创建者和管理员回忆任务目的用的备注;`task setup --description` 或 `--description-file` 是给评估者看的评估说明,两者不要混用。`--task-id` 通常可以省略,平台会根据任务名生成存储目录 id。
194
+
195
+ 把上一步返回的 `<task-id>`、`<dataset-a-id>`、`<dataset-b-id>` 填进去:
196
+
197
+ ```bash
198
+ gsb-cli task bind <task-id> --a <dataset-a-id> --b <dataset-b-id> --json
199
+ gsb-cli task setup <task-id> --min-per-person 0 --json
200
+ gsb-cli task config <task-id> --transparent-mode admin_only --stats admin_only --show-trace true --require-comments false --json
201
+ gsb-cli task preflight <task-id> --json
202
+ gsb-cli task publish <task-id> --json
203
+ ```
204
+
205
+ 关键配置项分布:
206
+
207
+ | 配置项 | 命令 | 说明 |
208
+ | --- | --- | --- |
209
+ | `--min-per-person` | `task setup` | 每位评估者最少评估题数;`0` 表示全量 |
210
+ | `--anchor-count` | `task setup` | 指定锚点题数量;不传时平台按固定规则抽样 |
211
+ | `--description` / `--description-file` | `task setup` | 给评估者看的任务说明 |
212
+ | `--transparent-mode` | `task config` | 版本名可见性,常用 `admin_only` |
213
+ | `--stats` | `task config` | 统计面板可见性,常用 `admin_only` |
214
+ | `--show-trace` | `task config` | 是否展示 trace |
215
+ | `--require-comments` | `task config` | 是否强制评论必填 |
216
+
217
+ `task setup` 会返回 `setup_effects`,说明平台生成的分配策略、锚点题数量、额外评估维度和评估者顺序数量。`require_comments` 不在 `task setup` 中设置,必须使用 `task config`。
218
+
219
+ 导出结果:
220
+
221
+ ```bash
222
+ gsb-cli results summary <task-id> --all --json
223
+ gsb-cli results export <task-id> --format json --output ./exports --json
224
+ ```
225
+
226
+ 如果本地已经生成 HTML 报告 / JSON 摘要,可以上传到任务归档;也可以查看和下载平台侧已有报告:
227
+
228
+ ```bash
229
+ gsb-cli report upload <task-id> ./decision_report.html ./decision_summary.json --json
230
+ gsb-cli report status <task-id> --json
231
+ gsb-cli report download <task-id> --type html --output ./decision_report.html --json
232
+ gsb-cli report download <task-id> --type json --output ./decision_summary.json --json
233
+ ```
234
+
235
+ `report upload` 会把本地 `.html` 和 `.json` 文本文件写入任务归档。上传需要当前账号有任务管理权限。
236
+
237
+ 如果任务已经完成,也可以把任务归档:
238
+
239
+ ```bash
240
+ gsb-cli task archive <task-id> --json
241
+ ```
242
+
243
+ ## 数据格式
244
+
245
+ 每个版本是一个目录,每条 case 是目录第一层的一个 JSON 文件。
246
+
247
+ ```text
248
+ version_a/
249
+ q_0001.json
250
+ q_0002.json
251
+
252
+ version_b/
253
+ q_0001.json
254
+ q_0002.json
255
+ ```
256
+
257
+ 规则:
258
+
259
+ - A/B 两边同一条 case 必须使用完全相同的文件名。
260
+ - 文件名去掉 `.json` 后就是 `query_id`。
261
+ - 只读取目录第一层的 `.json` 文件,不递归读取子目录。
262
+ - 每个 JSON 文件的顶层必须是 object。
263
+ - CSV、XLSX、JSONL、NDJSON、TSV 需要先转换成“一条 case 一个 JSON 文件”的目录结构。
264
+
265
+ 最小 JSON 示例:
266
+
267
+ ```json
268
+ {
269
+ "query": "用户想买一台适合露营的便携咖啡机",
270
+ "response": "推荐优先考虑手压式或胶囊式便携咖啡机..."
271
+ }
272
+ ```
273
+
274
+ 如果你的字段不是 `query` / `response`,可以上传任务级 `renderer.js`:
275
+
276
+ ```bash
277
+ gsb-cli task renderer upload <task-id> ./renderer.js --json
278
+ ```
279
+
280
+ ## 常用命令
281
+
282
+ | 场景 | 命令 |
283
+ | --- | --- |
284
+ | 检查平台 | `gsb-cli doctor` |
285
+ | 检查 CLI 版本 | `gsb-cli version --check` |
286
+ | 查看 skill 安装 | `gsb-cli skill status --target all` |
287
+ | 手动安装 skill | `gsb-cli skill install --target codex --mode copy --force` |
288
+ | 登录 | `gsb-cli auth login --username <user> --password <password>` |
289
+ | 注册账号 | `gsb-cli auth register --username <user> --password <password>` |
290
+ | 查看当前用户 | `gsb-cli auth whoami` |
291
+ | 退出登录 | `gsb-cli auth logout` |
292
+ | 检查数据 | `gsb-cli dataset check --a ./baseline --b ./candidate` |
293
+ | 上传数据 | `gsb-cli dataset upload --a ./baseline --b ./candidate` |
294
+ | 查看数据集 | `gsb-cli dataset list` |
295
+ | 创建任务 | `gsb-cli task create --name "candidate vs baseline" --purpose "评估 candidate 相比 baseline 的回答质量和上线风险"` |
296
+ | 绑定数据 | `gsb-cli task bind <task-id> --a <dataset-a-id> --b <dataset-b-id>` |
297
+ | 配置任务 | `gsb-cli task setup <task-id> --min-per-person 0` |
298
+ | 配置权限/评论 | `gsb-cli task config <task-id> --transparent-mode admin_only --stats admin_only --show-trace true --require-comments false` |
299
+ | 发布前检查 | `gsb-cli task preflight <task-id>` |
300
+ | 发布任务 | `gsb-cli task publish <task-id>` |
301
+ | 归档任务 | `gsb-cli task archive <task-id>` |
302
+ | 上传 renderer | `gsb-cli task renderer upload <task-id> ./renderer.js` |
303
+ | 上传归档报告 | `gsb-cli report upload <task-id> ./decision_report.html ./decision_summary.json` |
304
+ | 查看归档报告 | `gsb-cli report status <task-id>` |
305
+ | 下载 HTML 报告 | `gsb-cli report download <task-id> --type html --output ./decision_report.html` |
306
+ | 下载 JSON 摘要 | `gsb-cli report download <task-id> --type json --output ./decision_summary.json` |
307
+ | 查看汇总 | `gsb-cli results summary <task-id> --all` |
308
+ | 导出结果 | `gsb-cli results export <task-id> --format json --output ./exports` |
309
+
310
+ ## 全局参数
311
+
312
+ 全局参数可以放在命令前,也可以放在命令后。
313
+
314
+ | 参数 | 说明 |
315
+ | --- | --- |
316
+ | `--base-url <url>` | GSB 平台地址。默认读取 `GSB_BASE_URL`,否则使用 `http://localhost:8888` |
317
+ | `--profile <name>` | 本地 session profile,默认 `default` |
318
+ | `--username <user>` | 可选;配合 `--password` 或 `GSB_PASSWORD` 自动登录 |
319
+ | `--password <password>` | 可选;自动登录密码,自动化场景建议用 `GSB_PASSWORD` |
320
+ | `--json` | 输出机器可解析 JSON |
321
+ | `--help` | 查看帮助 |
322
+ | `--version` | 查看版本 |
323
+
324
+ 本地 session 默认保存到:
325
+
326
+ ```text
327
+ ~/.chatbuy_gsb_eval_cli/sessions.json
328
+ ```
329
+
330
+ 可以通过环境变量覆盖:
331
+
332
+ ```bash
333
+ export GSB_CLI_SESSION="/path/to/sessions.json"
334
+ ```
335
+
336
+ ## 平台 workspace 映射
337
+
338
+ 这些路径存在于 GSB 平台服务器侧,仅用于排障和理解 CLI 副作用。正常使用时不要绕过 CLI 直接修改。
339
+
340
+ | CLI 操作 | 平台 workspace 结果 |
341
+ | --- | --- |
342
+ | `dataset upload` | 复制 JSON 到 `workspace/uploads/<username>/<dataset-name>/`,并更新 `workspace/uploads/_meta.json` |
343
+ | `task create` | 创建 `workspace/tasks/<task-id>/`,并更新任务注册表 |
344
+ | `task bind` | 写入 `workspace/tasks/<task-id>/data_a/`、`data_b/` 和版本映射 |
345
+ | `task setup` | 写入 `workspace/tasks/<task-id>/_config.json`,包含分配策略、锚点题、评估维度和 visibility |
346
+ | `task config` | 更新同一个 `_config.json` 中的 `visibility` |
347
+ | `task renderer upload` | 写入 `workspace/tasks/<task-id>/renderer.js` |
348
+ | `results export` | 在 `workspace/tasks/<task-id>/exports/` 生成导出文件 |
349
+ | `report upload` | 写入 `workspace/tasks/<task-id>/report/` |
350
+ | 评估者提交 | 写入 `workspace/tasks/<task-id>/rating_result/eval_<user>.json` |
351
+
352
+ ## 结构化错误
353
+
354
+ CLI 失败时会尽量返回可修复的问题,而不是只给 HTTP 错误。
355
+
356
+ 典型输出字段:
357
+
358
+ ```json
359
+ {
360
+ "ok": false,
361
+ "issues": [
362
+ {
363
+ "code": "ZERO_COMMON_ITEMS",
364
+ "problem": "A/B 版本没有同名 JSON 文件,评估任务会是 0 条",
365
+ "evidence": {
366
+ "count_a": 10,
367
+ "count_b": 10,
368
+ "common_count": 0
369
+ },
370
+ "next_step": "把同一条 case 在两个版本目录内保存为完全相同的文件名,例如 item_0001.json。"
371
+ }
372
+ ]
373
+ }
374
+ ```
375
+
376
+ Agent 应优先读取 `issues[].next_step` 修复问题,再继续原流程。
377
+
378
+ ## 常见问题
379
+
380
+ ### `gsb-cli: command not found`
381
+
382
+ 重新安装:
383
+
384
+ ```bash
385
+ npm install -g https://github.com/GasonW/gsb-cli/archive/refs/heads/main.tar.gz
386
+ ```
387
+
388
+ 如果仍然不可用,检查 npm 全局 bin 目录是否在 `PATH` 里。
389
+
390
+ ### `doctor` 提示平台不可访问
391
+
392
+ 先确认平台 URL:
393
+
394
+ ```bash
395
+ gsb-cli doctor --base-url https://<gsb-platform-url>
396
+ ```
397
+
398
+ 如果平台需要 VPN 或办公网络,先连接对应网络。
399
+
400
+ ### 远端平台绑定本地路径失败
401
+
402
+ 这是预期行为。远端服务器不能读取你电脑上的路径。
403
+
404
+ 正确流程:
405
+
406
+ ```bash
407
+ gsb-cli dataset upload --a ./baseline --b ./candidate --json
408
+ gsb-cli task bind <task-id> --a <dataset-a-id> --b <dataset-b-id> --json
409
+ ```
410
+
411
+ 只有当平台服务也运行在同一台机器上,并且能读到同一路径时,才适合直接绑定本地路径。
412
+
413
+ ## 开发
414
+
415
+ 只有在开发 CLI 本身时才需要 clone 仓库:
416
+
417
+ ```bash
418
+ git clone https://github.com/GasonW/gsb-cli.git
419
+ cd gsb-cli
420
+ npm install
421
+ npm test
422
+ ```
423
+
424
+ 本地运行:
425
+
426
+ ```bash
427
+ npm run build
428
+ node dist/src/index.js --help
429
+ ```
430
+
431
+ 项目运行时依赖为零,开发依赖主要是 TypeScript 和 Node 类型定义。测试使用 Node 内置 test runner。
@@ -0,0 +1,106 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ data;
4
+ url;
5
+ constructor(status, data, url) {
6
+ super(`HTTP ${status}: ${JSON.stringify(data)}`);
7
+ this.status = status;
8
+ this.data = data;
9
+ this.url = url;
10
+ }
11
+ }
12
+ export class ApiClient {
13
+ baseUrl;
14
+ sessionToken;
15
+ timeoutMs;
16
+ constructor(options) {
17
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
18
+ this.sessionToken = options.sessionToken || "";
19
+ this.timeoutMs = options.timeoutMs ?? 60_000;
20
+ }
21
+ async request(method, path, data, options = {}) {
22
+ const url = this.url(path);
23
+ const headers = {};
24
+ let body;
25
+ if (data !== undefined) {
26
+ body = JSON.stringify(data);
27
+ headers["content-type"] = "application/json";
28
+ }
29
+ if (this.sessionToken) {
30
+ headers.cookie = `session_token=${this.sessionToken}`;
31
+ }
32
+ let response;
33
+ try {
34
+ response = await fetch(url, {
35
+ method: method.toUpperCase(),
36
+ headers,
37
+ body,
38
+ signal: AbortSignal.timeout(this.timeoutMs),
39
+ });
40
+ }
41
+ catch (error) {
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ throw new ApiError(0, { error: message }, url);
44
+ }
45
+ this.captureCookie(response.headers);
46
+ if (!response.ok) {
47
+ throw new ApiError(response.status, await decodeResponse(response), url);
48
+ }
49
+ if (options.expectBytes) {
50
+ return { bytes: Buffer.from(await response.arrayBuffer()), headers: response.headers };
51
+ }
52
+ return (await decodeResponse(response));
53
+ }
54
+ async login(username, password) {
55
+ return this.request("POST", "/api/auth/login", { username, password });
56
+ }
57
+ async register(username, password) {
58
+ return this.request("POST", "/api/auth/register", { username, password });
59
+ }
60
+ url(path) {
61
+ if (/^https?:\/\//i.test(path)) {
62
+ return path;
63
+ }
64
+ return `${this.baseUrl}/${path.replace(/^\/+/, "")}`;
65
+ }
66
+ captureCookie(headers) {
67
+ const headerList = [];
68
+ const getSetCookie = headers.getSetCookie;
69
+ if (typeof getSetCookie === "function") {
70
+ headerList.push(...getSetCookie.call(headers));
71
+ }
72
+ const single = headers.get("set-cookie");
73
+ if (single) {
74
+ headerList.push(single);
75
+ }
76
+ for (const header of headerList) {
77
+ const match = /(?:^|;\s*)session_token=([^;]+)/.exec(header);
78
+ if (match?.[1]) {
79
+ this.sessionToken = match[1];
80
+ }
81
+ }
82
+ }
83
+ }
84
+ export function serverError(data) {
85
+ if (data && typeof data === "object") {
86
+ const obj = data;
87
+ return String(obj.error || obj.raw || JSON.stringify(obj));
88
+ }
89
+ return String(data);
90
+ }
91
+ async function decodeResponse(response) {
92
+ const raw = await response.text();
93
+ if (!raw) {
94
+ return {};
95
+ }
96
+ const contentType = response.headers.get("content-type") || "";
97
+ if (contentType.includes("json") || /^[\s\r\n]*[\[{]/.test(raw)) {
98
+ try {
99
+ return JSON.parse(raw);
100
+ }
101
+ catch {
102
+ return { raw };
103
+ }
104
+ }
105
+ return { raw };
106
+ }
@@ -0,0 +1,145 @@
1
+ const globalValueFlags = {
2
+ "--base-url": "baseUrl",
3
+ "--profile": "profile",
4
+ "--username": "username",
5
+ "--password": "password",
6
+ };
7
+ export function parseArgs(rawArgv, env = process.env, cwd = process.cwd()) {
8
+ const globals = {
9
+ profile: "default",
10
+ json: false,
11
+ help: false,
12
+ version: false,
13
+ rawArgv,
14
+ env,
15
+ cwd,
16
+ };
17
+ const cleaned = [];
18
+ for (let i = 0; i < rawArgv.length; i += 1) {
19
+ const part = rawArgv[i] ?? "";
20
+ if (part === "--json") {
21
+ globals.json = true;
22
+ continue;
23
+ }
24
+ if (part === "--help" || part === "-h") {
25
+ globals.help = true;
26
+ continue;
27
+ }
28
+ if (part === "--version" || part === "-v") {
29
+ globals.version = true;
30
+ continue;
31
+ }
32
+ const eq = part.indexOf("=");
33
+ const key = eq >= 0 ? part.slice(0, eq) : part;
34
+ if (key in globalValueFlags) {
35
+ const target = globalValueFlags[key];
36
+ const value = eq >= 0 ? part.slice(eq + 1) : rawArgv[i + 1];
37
+ if (value === undefined) {
38
+ throw new CliUsageError(`${key} requires a value`);
39
+ }
40
+ globals[target] = value;
41
+ if (eq < 0) {
42
+ i += 1;
43
+ }
44
+ continue;
45
+ }
46
+ cleaned.push(part);
47
+ }
48
+ return { globals, args: cleaned };
49
+ }
50
+ export class CliUsageError extends Error {
51
+ constructor(message) {
52
+ super(message);
53
+ this.name = "CliUsageError";
54
+ }
55
+ }
56
+ export class OptionReader {
57
+ values;
58
+ constructor(values) {
59
+ this.values = [...values];
60
+ }
61
+ rest() {
62
+ return [...this.values];
63
+ }
64
+ takeString(name, defaultValue = "") {
65
+ const flag = `--${name}`;
66
+ for (let i = 0; i < this.values.length; i += 1) {
67
+ const value = this.values[i] ?? "";
68
+ if (value === flag) {
69
+ const next = this.values[i + 1];
70
+ if (next === undefined) {
71
+ throw new CliUsageError(`${flag} requires a value`);
72
+ }
73
+ this.values.splice(i, 2);
74
+ return next;
75
+ }
76
+ if (value.startsWith(`${flag}=`)) {
77
+ this.values.splice(i, 1);
78
+ return value.slice(flag.length + 1);
79
+ }
80
+ }
81
+ return defaultValue;
82
+ }
83
+ takeOptionalString(name) {
84
+ const value = this.takeString(name, "\u0000");
85
+ return value === "\u0000" ? undefined : value;
86
+ }
87
+ takeNumber(name, defaultValue) {
88
+ const raw = this.takeOptionalString(name);
89
+ if (raw === undefined) {
90
+ return defaultValue;
91
+ }
92
+ const value = Number.parseInt(raw, 10);
93
+ if (!Number.isFinite(value)) {
94
+ throw new CliUsageError(`--${name} expects an integer, got ${raw}`);
95
+ }
96
+ return value;
97
+ }
98
+ takeFloat(name, defaultValue) {
99
+ const raw = this.takeOptionalString(name);
100
+ if (raw === undefined) {
101
+ return defaultValue;
102
+ }
103
+ const value = Number.parseFloat(raw);
104
+ if (!Number.isFinite(value)) {
105
+ throw new CliUsageError(`--${name} expects a number, got ${raw}`);
106
+ }
107
+ return value;
108
+ }
109
+ takeBoolean(name) {
110
+ const raw = this.takeOptionalString(name);
111
+ if (raw === undefined) {
112
+ return undefined;
113
+ }
114
+ const normalized = raw.trim().toLowerCase();
115
+ if (["1", "true", "yes", "y", "on", "是"].includes(normalized)) {
116
+ return true;
117
+ }
118
+ if (["0", "false", "no", "n", "off", "否"].includes(normalized)) {
119
+ return false;
120
+ }
121
+ throw new CliUsageError(`--${name} expects boolean, got ${raw}`);
122
+ }
123
+ takeFlag(name) {
124
+ const flag = `--${name}`;
125
+ const idx = this.values.indexOf(flag);
126
+ if (idx >= 0) {
127
+ this.values.splice(idx, 1);
128
+ return true;
129
+ }
130
+ return false;
131
+ }
132
+ requireNoUnknown() {
133
+ const unknown = this.values.find((value) => value.startsWith("-"));
134
+ if (unknown) {
135
+ throw new CliUsageError(`unknown option ${unknown}`);
136
+ }
137
+ }
138
+ }
139
+ export function requireArg(args, index, name) {
140
+ const value = args[index];
141
+ if (!value || value.startsWith("-")) {
142
+ throw new CliUsageError(`${name} is required`);
143
+ }
144
+ return value;
145
+ }