gangtise-openapi-cli 0.10.3 → 0.10.5

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 CHANGED
@@ -1,19 +1,13 @@
1
1
  # Gangtise OpenAPI CLI
2
2
 
3
- 一个可直接调用 Gangtise OpenAPI 的命令行工具。
3
+ 一个可直接调用 Gangtise OpenAPI 获取金融数据的命令行工具,同时提供Agent Skill。
4
4
 
5
- ## 安装
5
+ ## 首次安装
6
6
 
7
7
  ```bash
8
8
  npm install -g gangtise-openapi-cli
9
9
  ```
10
10
 
11
- 更新到最新版:
12
-
13
- ```bash
14
- npm update -g gangtise-openapi-cli
15
- ```
16
-
17
11
  验证安装:
18
12
 
19
13
  ```bash
@@ -28,8 +22,22 @@ cd gangtise-openapi-cli
28
22
  npm install
29
23
  npm run dev -- --help
30
24
  ```
25
+ ## 版本更新
26
+
27
+ 查看当前版本(自动与线上版本比对):
28
+
29
+ ```bash
30
+ gangtise --version
31
+ ```
32
+
33
+ 手动更新到最新版:
34
+
35
+ ```bash
36
+ npm update -g gangtise-openapi-cli
37
+ ```
31
38
 
32
- ## 配置
39
+
40
+ ## 环境配置
33
41
 
34
42
  优先读取以下环境变量:
35
43
 
@@ -40,8 +48,53 @@ export GANGTISE_BASE_URL="https://open.gangtise.com"
40
48
  export GANGTISE_TOKEN="Bearer xxx"
41
49
  ```
42
50
 
43
- 如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地。
51
+ 如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。
52
+
53
+
54
+ ## AI Agent Skill
55
+
56
+ 本项目包含 Skill 定义(`gangtise-openapi/SKILL.md`),可让 AI agent 自动调用 `gangtise` CLI 完成投研数据查询。支持以下 AI 编程助手:
57
+
58
+ - [Claude Code](https://claude.ai/claude-code) — `~/.claude/skills/`
59
+ - [OpenClaw](https://github.com/openclaw/openclaw) — `~/.openclaw/skills/`
60
+ - [Hermes](https://github.com/nicepkg/hermes) — `~/.hermes/skills/`
61
+
62
+ Skill 目录结构:
63
+
64
+ ```
65
+ gangtise-openapi/
66
+ ├── SKILL.md # 主 skill 文件(命令参考、参数枚举、使用规则)
67
+ └── references/
68
+ ├── fields.md # 字段中英文对照速查表
69
+ └── lookup-ids.md # 常用 ID 速查表(行业/券商/机构/公告分类等)
70
+ ```
71
+
72
+ 安装:
73
+
74
+ ```bash
75
+ # Claude Code
76
+ cp -r gangtise-openapi ~/.claude/skills/gangtise-openapi
77
+
78
+ # OpenClaw
79
+ cp -r gangtise-openapi ~/.openclaw/skills/gangtise-openapi
80
+
81
+ # Hermes
82
+ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
83
+ ```
84
+
85
+ > **版本更新**:每次 CLI 发版时,`gangtise-openapi/SKILL.md` 的 `version` 字段会自动同步。更新 CLI 后,请将项目中的 `gangtise-openapi/` 目录重新复制到对应的 skills 目录覆盖更新:
86
+ >
87
+ > ```bash
88
+ > # 示例:更新 Claude Code 的 skill
89
+ > cp -r gangtise-openapi ~/.claude/skills/gangtise-openapi
90
+ > ```
91
+ >
92
+ > 可通过查看 SKILL.md 头部的 `version` 字段确认当前版本。
44
93
 
94
+ 安装后,可以用自然语言触发,例如:
95
+ - "帮我查今天所有的研报"
96
+ - "用 gangtise 命令查一下贵州茅台的日K线"
97
+ - "导出最近一周的首席观点到 jsonl"
45
98
 
46
99
  ## 数据接口覆盖
47
100
 
@@ -83,43 +136,6 @@ export GANGTISE_TOKEN="Bearer xxx"
83
136
  | | `my-conference-list` / `my-conference-download` | 我的会议列表与下载 |
84
137
  | **Raw** | `call` | 原始接口调用(可访问任意 endpoint) |
85
138
 
86
- ## AI Agent Skill
87
-
88
- 本项目包含 SKill 定义(`gangtise-openapi/SKILL.md`),可让 AI agent 自动调用 `gangtise` CLI 完成投研数据查询。支持以下 AI 编程助手:
89
-
90
- - [Claude Code](https://claude.ai/claude-code) — `~/.claude/skills/`
91
- - [OpenClaw](https://github.com/openclaw/openclaw) — `~/.openclaw/skills/`
92
- - [Hermes](https://github.com/nicepkg/hermes) — `~/.hermes/skills/`
93
-
94
- Skill 目录结构:
95
-
96
- ```
97
- gangtise-openapi/
98
- ├── SKILL.md # 主 skill 文件(命令参考、参数枚举、使用规则)
99
- └── references/
100
- ├── fields.md # 字段中英文对照速查表
101
- └── lookup-ids.md # 常用 ID 速查表(行业/券商/机构/公告分类等)
102
- ```
103
-
104
- 安装:
105
-
106
- ```bash
107
- # Claude Code
108
- cp -r gangtise-openapi ~/.claude/skills/gangtise-openapi
109
-
110
- # OpenClaw
111
- cp -r gangtise-openapi ~/.openclaw/skills/gangtise-openapi
112
-
113
- # Hermes
114
- cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
115
- ```
116
-
117
- 安装后,可以用自然语言触发,例如:
118
- - "帮我查今天所有的研报"
119
- - "用 gangtise 命令查一下贵州茅台的日K线"
120
- - "导出最近一周的首席观点到 jsonl"
121
-
122
-
123
139
  ## 命令概览
124
140
 
125
141
  - `gangtise auth ...`
@@ -176,6 +192,8 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
176
192
  - **有时间范围时**(传了 `--start-time/--end-time` 或 `--start-date/--end-date`):**省略 `--size`**,CLI 自动翻页查全
177
193
  - **无时间范围时**(未传时间参数):默认 `--size 200`,防止一次查询数据量过大
178
194
  - 如果显式传了 `--size`,则按指定值翻页,直到达到 `size` 或数据取完
195
+ - 安全上限:自动翻页最多 1000 页,防止异常循环
196
+ - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`),同时 stderr 输出 `Total: N, showing: M`
179
197
 
180
198
  ## 智能文件命名
181
199
 
@@ -279,7 +297,7 @@ gangtise ai peer-comparison --security-code 600519.SH
279
297
  gangtise ai earnings-review --security-code 600519.SH --period 2025q3
280
298
  gangtise ai theme-tracking --theme-id 121000131 --date 2026-03-01 --type morning
281
299
  gangtise ai hot-topic --start-date 2026-03-22 --end-date 2026-03-27 --category morningBriefing --category noonBriefing --with-related-securities --with-close-reading
282
- # 不传 --category 默认查全部类型(早报+午报+盘中快报+晚报),--with-related-securities 和 --with-close-reading 默认开启
300
+ # 不传 --category 默认查全部类型(早报+午报+盘中快报+晚报),--with-related-securities 和 --with-close-reading 默认开启,可用 --no-with-related-securities / --no-with-close-reading 关闭
283
301
  gangtise ai hot-topic --start-date 2026-04-15 --end-date 2026-04-17
284
302
  gangtise ai research-outline --security-code 600519.SH
285
303
  # 管理层讨论-财报
@@ -328,11 +346,13 @@ gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
328
346
  支持:
329
347
 
330
348
  - `table`
331
- - `json`
332
- - `jsonl`
349
+ - `json`(分页结果保留 `{total, list}` 结构)
350
+ - `jsonl`(每行一条记录)
333
351
  - `csv`
334
352
  - `markdown`
335
353
 
354
+ 所有格式均支持 `--output <path>` 输出到文件(自动创建父目录)。
355
+
336
356
  ## 常见错误
337
357
 
338
358
  | 错误码 | 说明 |
@@ -349,3 +369,5 @@ gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
349
369
  | `999999` | Gangtise 系统错误,请稍后重试 |
350
370
  | `433007` | 不支持该数据源(`knowledge-resource-download` 需正确的 `resourceType + sourceId` 组合) |
351
371
  | `430007` | 行情查询超出限制(日K线/分钟K线不传 `--security` 返回全市场,数据量过大;请指定证券代码或缩短日期范围) |
372
+ | `410110` | 异步任务生成中(非终态,需继续轮询) |
373
+ | `410111` | 异步任务生成失败(终态,不可重试) |
package/dist/src/cli.js CHANGED
@@ -37,7 +37,8 @@ function getTitleCachePath() {
37
37
  }
38
38
  return _titleCachePath;
39
39
  }
40
- const TITLE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
40
+ const TITLE_LOOKUP_SIZE = 200;
41
+ const TITLE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
41
42
  async function readTitleCache() {
42
43
  try {
43
44
  const fs = await import("node:fs/promises");
@@ -63,11 +64,15 @@ function lookupTitleCache(data, endpoint, id) {
63
64
  }
64
65
  async function printData(data, format, output, cache) {
65
66
  const normalized = await normalizeRows(data);
66
- // Populate title cache from list results
67
- if (cache && Array.isArray(normalized)) {
67
+ const items = Array.isArray(normalized)
68
+ ? normalized
69
+ : (normalized && typeof normalized === "object" && Array.isArray(normalized.list))
70
+ ? normalized.list
71
+ : null;
72
+ if (cache && items) {
68
73
  const titleField = cache.titleField ?? "title";
69
74
  const titles = {};
70
- for (const row of normalized) {
75
+ for (const row of items) {
71
76
  if (row && typeof row === "object") {
72
77
  const r = row;
73
78
  const id = r[cache.idField];
@@ -79,6 +84,13 @@ async function printData(data, format, output, cache) {
79
84
  if (Object.keys(titles).length > 0)
80
85
  writeTitleCache(cache.endpointKey, titles).catch(() => { });
81
86
  }
87
+ if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
88
+ const meta = normalized;
89
+ if (typeof meta.total === "number") {
90
+ const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
91
+ process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
92
+ }
93
+ }
82
94
  const content = await renderOutput(normalized, format);
83
95
  if (output) {
84
96
  await saveOutputIfNeeded(content, output);
@@ -141,7 +153,7 @@ async function resolveTitle(client, result, listEndpoint, idField, idValue, titl
141
153
  catch { /* ignore */ }
142
154
  // 2. Fallback: query list API (scan recent 200 items)
143
155
  try {
144
- const resp = await client.call(listEndpoint, { from: 0, size: 200 });
156
+ const resp = await client.call(listEndpoint, { from: 0, size: TITLE_LOOKUP_SIZE });
145
157
  const items = Array.isArray(resp) ? resp : (resp.list ?? []);
146
158
  const match = items.find(f => String(f[idField]) === String(idValue));
147
159
  const rawTitle = match?.[titleField];
@@ -179,6 +191,46 @@ async function saveDownloadResult(result, fallbackName, output) {
179
191
  }
180
192
  throw new DownloadError("Unexpected download response");
181
193
  }
194
+ const POLL_MAX_ATTEMPTS = 12;
195
+ const POLL_DELAY_MS = 15_000;
196
+ async function pollAsyncContent(client, getContentEndpoint, dataId, format, output) {
197
+ for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
198
+ try {
199
+ const result = await client.call(getContentEndpoint, { dataId });
200
+ if (result?.content) {
201
+ await printData(result, format, output);
202
+ return true;
203
+ }
204
+ }
205
+ catch (error) {
206
+ if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
207
+ throw error;
208
+ }
209
+ }
210
+ if (attempt < POLL_MAX_ATTEMPTS) {
211
+ process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: content not ready, retrying in 15s...\n`);
212
+ await new Promise(resolve => setTimeout(resolve, POLL_DELAY_MS));
213
+ }
214
+ }
215
+ return false;
216
+ }
217
+ function checkAsyncContent(client, getContentEndpoint, dataId, format, output) {
218
+ return (async () => {
219
+ try {
220
+ const result = await client.call(getContentEndpoint, { dataId });
221
+ if (result?.content) {
222
+ await printData(result, format, output);
223
+ return;
224
+ }
225
+ }
226
+ catch (error) {
227
+ if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
228
+ throw error;
229
+ }
230
+ }
231
+ process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
232
+ })();
233
+ }
182
234
  function addTimeFilters(command) {
183
235
  return command
184
236
  .option("--from <number>", "Starting offset", "0")
@@ -424,7 +476,6 @@ ai.command("peer-comparison").requiredOption("--security-code <code>").option("-
424
476
  });
425
477
  ai.command("earnings-review").requiredOption("--security-code <code>").requiredOption("--period <period>", "Report period (e.g. 2025q3, 2025interim, 2025annual)").option("--wait", "Wait for content generation (blocking, up to 3 min)").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
426
478
  const client = await createClient();
427
- // Step 1: get dataId
428
479
  const idResult = await client.call("ai.earnings-review.get-id", { securityCode: options.securityCode, period: options.period });
429
480
  const dataId = idResult?.dataId;
430
481
  if (!dataId) {
@@ -432,56 +483,20 @@ ai.command("earnings-review").requiredOption("--security-code <code>").requiredO
432
483
  process.exitCode = 1;
433
484
  return;
434
485
  }
435
- // Non-blocking: return dataId immediately
436
486
  if (!options.wait) {
437
487
  process.stderr.write(`Earnings review task submitted. dataId: ${dataId}\n`);
438
488
  process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: `Run 'gangtise ai earnings-review-check --data-id ${dataId}' in ~2 minutes to get results` })}\n`);
439
489
  return;
440
490
  }
441
- // Blocking (--wait): poll for content
442
491
  process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
443
- let attempts = 0;
444
- const maxAttempts = 12;
445
- const delayMs = 15_000;
446
- while (attempts < maxAttempts) {
447
- attempts++;
448
- try {
449
- const contentResult = await client.call("ai.earnings-review.get-content", { dataId });
450
- if (contentResult?.content) {
451
- await printData(contentResult, parseFormat(options.format), options.output);
452
- return;
453
- }
454
- }
455
- catch (error) {
456
- if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
457
- throw error;
458
- }
459
- }
460
- if (attempts < maxAttempts) {
461
- process.stderr.write(`Attempt ${attempts}/${maxAttempts}: content not ready, retrying in 15s...\n`);
462
- await new Promise(resolve => setTimeout(resolve, delayMs));
463
- }
492
+ if (!await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, parseFormat(options.format), options.output)) {
493
+ process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
494
+ process.exitCode = 1;
464
495
  }
465
- process.stderr.write(`Content not available after ${maxAttempts} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
466
- process.exitCode = 1;
467
496
  });
468
497
  ai.command("earnings-review-check").requiredOption("--data-id <id>", "dataId from earnings-review").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
469
498
  const client = await createClient();
470
- try {
471
- const contentResult = await client.call("ai.earnings-review.get-content", { dataId: options.dataId });
472
- if (contentResult?.content) {
473
- await printData(contentResult, parseFormat(options.format), options.output);
474
- return;
475
- }
476
- process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
477
- }
478
- catch (error) {
479
- if (error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中"))) {
480
- process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
481
- return;
482
- }
483
- throw error;
484
- }
499
+ await checkAsyncContent(client, "ai.earnings-review.get-content", options.dataId, parseFormat(options.format), options.output);
485
500
  });
486
501
  ai.command("theme-tracking").requiredOption("--theme-id <id>", "Theme ID (use lookup theme-id list)").requiredOption("--date <date>", "Date (yyyy-MM-dd)").option("--type <name>", "Report type: morning/night", collectList, []).option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
487
502
  const client = await createClient();
@@ -492,7 +507,7 @@ ai.command("research-outline").requiredOption("--security-code <code>").option("
492
507
  const client = await createClient();
493
508
  await printData(await client.call("ai.research-outline", { securityCode: options.securityCode }), parseFormat(options.format), options.output);
494
509
  });
495
- ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-date <date>", "Start date (yyyy-MM-dd)").option("--end-date <date>", "End date (yyyy-MM-dd)").option("--category <name>", "Report type: morningBriefing/noonBriefing/afternoonFlash/eveningBriefing", collectList, []).option("--with-related-securities", "Include related securities info", true).option("--with-close-reading", "Include close reading content", true).option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
510
+ ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-date <date>", "Start date (yyyy-MM-dd)").option("--end-date <date>", "End date (yyyy-MM-dd)").option("--category <name>", "Report type: morningBriefing/noonBriefing/afternoonFlash/eveningBriefing", collectList, []).option("--with-related-securities", "Include related securities info").option("--no-with-related-securities", "Exclude related securities info").option("--with-close-reading", "Include close reading content").option("--no-with-close-reading", "Exclude close reading content").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
496
511
  const client = await createClient();
497
512
  const ALL_CATEGORIES = ["morningBriefing", "noonBriefing", "afternoonFlash", "eveningBriefing"];
498
513
  await printData(await client.call("ai.hot-topic", {
@@ -501,8 +516,8 @@ ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option
501
516
  startDate: options.startDate,
502
517
  endDate: options.endDate,
503
518
  categoryList: options.category.length > 0 ? options.category : ALL_CATEGORIES,
504
- withRelatedSecurities: options.withRelatedSecurities || undefined,
505
- withCloseReading: options.withCloseReading || undefined,
519
+ withRelatedSecurities: options.withRelatedSecurities === false ? undefined : true,
520
+ withCloseReading: options.withCloseReading === false ? undefined : true,
506
521
  }), parseFormat(options.format), options.output);
507
522
  });
508
523
  ai.command("management-discuss-announcement").requiredOption("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)").requiredOption("--security-code <code>", "Security code (e.g. 000001.SZ)").addOption(new Option("--dimension <name>", "Discussion dimension").choices(["businessOperation", "financialPerformance", "developmentAndRisk"]).makeOptionMandatory()).option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
@@ -523,7 +538,6 @@ ai.command("management-discuss-earnings-call").requiredOption("--report-date <da
523
538
  });
524
539
  ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint text (max 1000 chars)").option("--wait", "Wait for content generation (blocking, up to 3 min)").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
525
540
  const client = await createClient();
526
- // Step 1: get dataId
527
541
  const idResult = await client.call("ai.viewpoint-debate.get-id", { viewpoint: options.viewpoint });
528
542
  const dataId = idResult?.dataId;
529
543
  if (!dataId) {
@@ -531,56 +545,20 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
531
545
  process.exitCode = 1;
532
546
  return;
533
547
  }
534
- // Non-blocking: return dataId immediately
535
548
  if (!options.wait) {
536
549
  process.stderr.write(`Viewpoint debate task submitted. dataId: ${dataId}\n`);
537
550
  process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: `Run 'gangtise ai viewpoint-debate-check --data-id ${dataId}' in ~2 minutes to get results` })}\n`);
538
551
  return;
539
552
  }
540
- // Blocking (--wait): poll for content
541
553
  process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
542
- let attempts = 0;
543
- const maxAttempts = 12;
544
- const delayMs = 15_000;
545
- while (attempts < maxAttempts) {
546
- attempts++;
547
- try {
548
- const contentResult = await client.call("ai.viewpoint-debate.get-content", { dataId });
549
- if (contentResult?.content) {
550
- await printData(contentResult, parseFormat(options.format), options.output);
551
- return;
552
- }
553
- }
554
- catch (error) {
555
- if (!(error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中")))) {
556
- throw error;
557
- }
558
- }
559
- if (attempts < maxAttempts) {
560
- process.stderr.write(`Attempt ${attempts}/${maxAttempts}: content not ready, retrying in 15s...\n`);
561
- await new Promise(resolve => setTimeout(resolve, delayMs));
562
- }
554
+ if (!await pollAsyncContent(client, "ai.viewpoint-debate.get-content", dataId, parseFormat(options.format), options.output)) {
555
+ process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
556
+ process.exitCode = 1;
563
557
  }
564
- process.stderr.write(`Content not available after ${maxAttempts} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
565
- process.exitCode = 1;
566
558
  });
567
559
  ai.command("viewpoint-debate-check").requiredOption("--data-id <id>", "dataId from viewpoint-debate").option("--format <format>", "Output format", "json").option("--output <path>").action(async (options) => {
568
560
  const client = await createClient();
569
- try {
570
- const contentResult = await client.call("ai.viewpoint-debate.get-content", { dataId: options.dataId });
571
- if (contentResult?.content) {
572
- await printData(contentResult, parseFormat(options.format), options.output);
573
- return;
574
- }
575
- process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
576
- }
577
- catch (error) {
578
- if (error instanceof ApiError && (error.code === "410110" || error.message?.includes("生成中"))) {
579
- process.stdout.write(`${JSON.stringify({ dataId: options.dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
580
- return;
581
- }
582
- throw error;
583
- }
561
+ await checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseFormat(options.format), options.output);
584
562
  });
585
563
  const vault = new Command("vault").description("Vault APIs");
586
564
  vault.command("drive-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--file-type <number>", "File type", collectNumberList, []).option("--space-type <number>", "Space type", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
@@ -617,7 +595,15 @@ program.addCommand(vault);
617
595
  program.addCommand(ai);
618
596
  program.command("raw").description("Raw API calls").addCommand(new Command("call").argument("<endpointKey>").option("--body <json>").option("--query <key=value>", "Query string pair", collectKeyValue, {}).option("--format <format>", "Output format", "json").option("--output <path>").action(async (endpointKey, options) => {
619
597
  const client = await createClient();
620
- const body = options.body ? JSON.parse(options.body) : undefined;
598
+ let body;
599
+ if (options.body) {
600
+ try {
601
+ body = JSON.parse(options.body);
602
+ }
603
+ catch {
604
+ throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
605
+ }
606
+ }
621
607
  const data = await client.call(endpointKey, body, options.query);
622
608
  if (data && typeof data === "object" && "data" in data && data.data instanceof Uint8Array) {
623
609
  await saveDownloadResult(data, "download.bin", options.output);
@@ -646,3 +632,23 @@ async function main() {
646
632
  }
647
633
  }
648
634
  void main();
635
+ // Background update check on --version
636
+ if (process.argv.includes("--version") || process.argv.includes("-V")) {
637
+ import("node:https").then((https) => {
638
+ const req = https.get("https://registry.npmjs.org/gangtise-openapi-cli/latest", (res) => {
639
+ let body = "";
640
+ res.on("data", (chunk) => { body += chunk; });
641
+ res.on("end", () => {
642
+ try {
643
+ const latest = JSON.parse(body).version;
644
+ if (latest && latest !== CLI_VERSION) {
645
+ process.stderr.write(`\nUpdate available: ${CLI_VERSION} → ${latest}\nRun: npm update -g gangtise-openapi-cli\n`);
646
+ }
647
+ }
648
+ catch { /* ignore */ }
649
+ });
650
+ });
651
+ req.on("error", () => { });
652
+ req.setTimeout(3000, () => { req.destroy(); });
653
+ }).catch(() => { });
654
+ }
@@ -4,18 +4,22 @@ import { ConfigError } from "./errors.js";
4
4
  export async function readTokenCache(filePath) {
5
5
  try {
6
6
  const content = await fs.readFile(filePath, "utf8");
7
- return JSON.parse(content);
7
+ const parsed = JSON.parse(content);
8
+ if (parsed && typeof parsed === "object" && typeof parsed.accessToken === "string" && typeof parsed.expiresAt === "number") {
9
+ return parsed;
10
+ }
11
+ return null;
8
12
  }
9
13
  catch (error) {
10
14
  if (error.code === "ENOENT") {
11
15
  return null;
12
16
  }
13
- throw error;
17
+ return null;
14
18
  }
15
19
  }
16
20
  export async function writeTokenCache(filePath, cache) {
17
21
  await fs.mkdir(path.dirname(filePath), { recursive: true });
18
- await fs.writeFile(filePath, JSON.stringify(cache, null, 2), "utf8");
22
+ await fs.writeFile(filePath, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
19
23
  }
20
24
  export function isTokenCacheValid(cache, bufferSeconds = 300) {
21
25
  if (!cache?.accessToken || !cache.expiresAt) {
@@ -1,10 +1,11 @@
1
1
  import { request } from "undici";
2
- import { normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
2
+ import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
3
3
  import { ApiError, ValidationError } from "./errors.js";
4
4
  import { ENDPOINTS, ENDPOINT_REGISTRY } from "./endpoints.js";
5
5
  import { getLookupData } from "./lookupData/index.js";
6
6
  export class GangtiseClient {
7
7
  config;
8
+ refreshPromise = null;
8
9
  constructor(config) {
9
10
  this.config = config;
10
11
  }
@@ -13,17 +14,22 @@ export class GangtiseClient {
13
14
  return normalizeToken(this.config.token);
14
15
  }
15
16
  const cache = await readTokenCache(this.config.tokenCachePath);
16
- if (cache?.accessToken && cache.expiresAt - 300 > Math.floor(Date.now() / 1000)) {
17
+ if (isTokenCacheValid(cache)) {
17
18
  return normalizeToken(cache.accessToken);
18
19
  }
20
+ if (!this.refreshPromise) {
21
+ this.refreshPromise = this.doTokenRefresh().finally(() => { this.refreshPromise = null; });
22
+ }
23
+ return this.refreshPromise;
24
+ }
25
+ async doTokenRefresh() {
19
26
  const credentials = requireAccessCredentials(this.config.accessKey, this.config.secretKey);
20
27
  const envelope = await this.requestJson(ENDPOINTS.authLogin, {
21
28
  accessKey: credentials.accessKey,
22
29
  secretKey: credentials.secretKey,
23
30
  }, false);
24
31
  const accessToken = normalizeToken(envelope.accessToken);
25
- const issuedAt = envelope.time ?? Math.floor(Date.now() / 1000);
26
- const expiresAt = issuedAt + envelope.expiresIn;
32
+ const expiresAt = Math.floor(Date.now() / 1000) + envelope.expiresIn;
27
33
  await writeTokenCache(this.config.tokenCachePath, {
28
34
  ...envelope,
29
35
  accessToken,
@@ -83,7 +89,8 @@ export class GangtiseClient {
83
89
  let firstPage;
84
90
  let total;
85
91
  let nextFrom = startFrom;
86
- while (true) {
92
+ const MAX_PAGES = 1000;
93
+ for (let pageCount = 0; pageCount < MAX_PAGES; pageCount++) {
87
94
  const remaining = requestedSize === undefined
88
95
  ? maxPageSize
89
96
  : Math.min(maxPageSize, requestedSize - collected.length);
@@ -126,7 +133,7 @@ export class GangtiseClient {
126
133
  }
127
134
  }
128
135
  if (!firstPage) {
129
- return initialBody;
136
+ return { total: 0, list: [] };
130
137
  }
131
138
  return {
132
139
  ...firstPage,
@@ -160,12 +167,15 @@ export class GangtiseClient {
160
167
  bodyTimeout: this.config.timeoutMs,
161
168
  });
162
169
  const text = await response.body.text();
170
+ if (response.statusCode >= 500) {
171
+ throw new ApiError(`Server error (HTTP ${response.statusCode})`, undefined, response.statusCode, text.slice(0, 500));
172
+ }
163
173
  let parsed;
164
174
  try {
165
175
  parsed = JSON.parse(text);
166
176
  }
167
177
  catch {
168
- throw new ApiError('Failed to parse API response', undefined, response.statusCode, text);
178
+ throw new ApiError('Failed to parse API response', undefined, response.statusCode, text.slice(0, 500));
169
179
  }
170
180
  return this.unwrapEnvelope(parsed, response.statusCode);
171
181
  }
@@ -13,7 +13,10 @@ export async function getLookupData(key) {
13
13
  if (cache.has(key))
14
14
  return cache.get(key);
15
15
  const mod = await loaders[key]();
16
- const data = Object.values(mod)[0];
16
+ const values = Object.values(mod);
17
+ const data = values.find(v => Array.isArray(v));
18
+ if (!data)
19
+ throw new Error(`Lookup module "${key}" has no exported array`);
17
20
  cache.set(key, data);
18
21
  return data;
19
22
  }
@@ -7,7 +7,7 @@ export function normalizeRows(value) {
7
7
  }
8
8
  const record = value;
9
9
  if (Array.isArray(record.fieldList) && Array.isArray(record.list)) {
10
- return record.list.map((row) => {
10
+ const normalizedList = record.list.map((row) => {
11
11
  if (!Array.isArray(row))
12
12
  return row;
13
13
  return record.fieldList.reduce((acc, field, index) => {
@@ -15,9 +15,14 @@ export function normalizeRows(value) {
15
15
  return acc;
16
16
  }, {});
17
17
  });
18
+ const { fieldList, list, ...meta } = record;
19
+ const hasMeta = Object.keys(meta).length > 0;
20
+ return hasMeta ? { ...meta, list: normalizedList } : normalizedList;
18
21
  }
19
22
  if (Array.isArray(record.list)) {
20
- return record.list;
23
+ const { list, ...meta } = record;
24
+ const hasMeta = Object.keys(meta).length > 0;
25
+ return hasMeta ? { ...meta, list } : list;
21
26
  }
22
27
  return value;
23
28
  }
@@ -16,7 +16,15 @@ function toRows(value) {
16
16
  return value.map((item, index) => ({ index, value: item }));
17
17
  }
18
18
  if (value && typeof value === "object") {
19
- return [value];
19
+ const record = value;
20
+ if (Array.isArray(record.list)) {
21
+ const list = record.list;
22
+ if (list.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
23
+ return list;
24
+ }
25
+ return list.map((item, index) => ({ index, value: item }));
26
+ }
27
+ return [record];
20
28
  }
21
29
  return [{ value }];
22
30
  }
@@ -51,6 +59,9 @@ function renderCsv(rows) {
51
59
  }
52
60
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
53
61
  const escape = (value) => {
62
+ if (/^[=+\-@\t\r]/.test(value)) {
63
+ value = "'" + value;
64
+ }
54
65
  if (/[",\n]/.test(value)) {
55
66
  return `"${value.replaceAll("\"", "\"\"")}"`;
56
67
  }
@@ -65,8 +76,12 @@ export function renderOutput(value, format) {
65
76
  switch (format) {
66
77
  case "json":
67
78
  return JSON.stringify(value, null, 2);
68
- case "jsonl":
69
- return rows.map((row) => JSON.stringify(row)).join("\n");
79
+ case "jsonl": {
80
+ const items = value && typeof value === "object" && !Array.isArray(value) && Array.isArray(value.list)
81
+ ? value.list
82
+ : null;
83
+ return (items ?? rows).map((item) => JSON.stringify(item)).join("\n");
84
+ }
70
85
  case "csv":
71
86
  return renderCsv(rows);
72
87
  case "markdown":
@@ -80,6 +95,8 @@ export async function saveOutputIfNeeded(content, outputPath) {
80
95
  if (!outputPath) {
81
96
  return;
82
97
  }
98
+ const { dirname } = await import("node:path");
99
+ await (await import("node:fs/promises")).mkdir(dirname(outputPath), { recursive: true });
83
100
  if (typeof content === "string") {
84
101
  await fs.writeFile(outputPath, content, "utf8");
85
102
  return;
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.10.3";
2
+ export const CLI_VERSION = "0.10.5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  "build": "tsc -p tsconfig.json",
24
24
  "dev": "tsx src/cli.ts",
25
25
  "test": "vitest run",
26
- "prepare": "node -e \"const p=JSON.parse(require('fs').readFileSync('package.json','utf8'));require('fs').writeFileSync('src/version.ts','// Auto-generated — DO NOT EDIT\\nexport const CLI_VERSION = \\\"'+p.version+'\\\"\\n')\" && npm run build"
26
+ "prepare": "node scripts/prepare.cjs && npm run build"
27
27
  },
28
28
  "engines": {
29
29
  "node": ">=20"