gangtise-openapi-cli 0.10.4 → 0.10.6

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,6 +1,6 @@
1
1
  # Gangtise OpenAPI CLI
2
2
 
3
- 一个可直接调用 Gangtise OpenAPI 的命令行工具。
3
+ 一个可直接调用 Gangtise OpenAPI 获取金融数据的命令行工具,同时提供Agent Skill。
4
4
 
5
5
  ## 首次安装
6
6
 
@@ -48,7 +48,7 @@ export GANGTISE_BASE_URL="https://open.gangtise.com"
48
48
  export GANGTISE_TOKEN="Bearer xxx"
49
49
  ```
50
50
 
51
- 如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地。
51
+ 如果没有 `GANGTISE_TOKEN`,CLI 会自动调用 token 接口并缓存到本地(`~/.config/gangtise/token.json`,权限 0600)。
52
52
 
53
53
 
54
54
  ## AI Agent Skill
@@ -192,6 +192,8 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
192
192
  - **有时间范围时**(传了 `--start-time/--end-time` 或 `--start-date/--end-date`):**省略 `--size`**,CLI 自动翻页查全
193
193
  - **无时间范围时**(未传时间参数):默认 `--size 200`,防止一次查询数据量过大
194
194
  - 如果显式传了 `--size`,则按指定值翻页,直到达到 `size` 或数据取完
195
+ - 安全上限:自动翻页最多 1000 页,防止异常循环
196
+ - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`),同时 stderr 输出 `Total: N, showing: M`
195
197
 
196
198
  ## 智能文件命名
197
199
 
@@ -295,7 +297,7 @@ gangtise ai peer-comparison --security-code 600519.SH
295
297
  gangtise ai earnings-review --security-code 600519.SH --period 2025q3
296
298
  gangtise ai theme-tracking --theme-id 121000131 --date 2026-03-01 --type morning
297
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
298
- # 不传 --category 默认查全部类型(早报+午报+盘中快报+晚报),--with-related-securities 和 --with-close-reading 默认开启
300
+ # 不传 --category 默认查全部类型(早报+午报+盘中快报+晚报),--with-related-securities 和 --with-close-reading 默认开启,可用 --no-with-related-securities / --no-with-close-reading 关闭
299
301
  gangtise ai hot-topic --start-date 2026-04-15 --end-date 2026-04-17
300
302
  gangtise ai research-outline --security-code 600519.SH
301
303
  # 管理层讨论-财报
@@ -344,11 +346,13 @@ gangtise raw call insight.opinion.list --body '{"from":0,"size":120}'
344
346
  支持:
345
347
 
346
348
  - `table`
347
- - `json`
348
- - `jsonl`
349
+ - `json`(分页结果保留 `{total, list}` 结构)
350
+ - `jsonl`(每行一条记录)
349
351
  - `csv`
350
352
  - `markdown`
351
353
 
354
+ 所有格式均支持 `--output <path>` 输出到文件(自动创建父目录)。
355
+
352
356
  ## 常见错误
353
357
 
354
358
  | 错误码 | 说明 |
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,54 @@ 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
+ function isAsyncPending(error) {
197
+ return error instanceof ApiError && error.code === "410110";
198
+ }
199
+ async function pollAsyncContent(client, getContentEndpoint, dataId, format, output) {
200
+ for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
201
+ try {
202
+ const result = await client.call(getContentEndpoint, { dataId });
203
+ if (result?.content != null) {
204
+ await printData(result, format, output);
205
+ return true;
206
+ }
207
+ }
208
+ catch (error) {
209
+ if (error instanceof ApiError && error.code === "410111") {
210
+ process.stderr.write("Content generation failed (terminal). Do not retry.\n");
211
+ return false;
212
+ }
213
+ if (!isAsyncPending(error))
214
+ throw error;
215
+ }
216
+ if (attempt < POLL_MAX_ATTEMPTS) {
217
+ process.stderr.write(`Attempt ${attempt}/${POLL_MAX_ATTEMPTS}: content not ready, retrying in 15s...\n`);
218
+ await new Promise(resolve => setTimeout(resolve, POLL_DELAY_MS));
219
+ }
220
+ }
221
+ return false;
222
+ }
223
+ async function checkAsyncContent(client, getContentEndpoint, dataId, format, output) {
224
+ try {
225
+ const result = await client.call(getContentEndpoint, { dataId });
226
+ if (result?.content != null) {
227
+ await printData(result, format, output);
228
+ return;
229
+ }
230
+ }
231
+ catch (error) {
232
+ if (error instanceof ApiError && error.code === "410111") {
233
+ process.stderr.write("Content generation failed (terminal). Do not retry.\n");
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+ if (!isAsyncPending(error))
238
+ throw error;
239
+ }
240
+ process.stdout.write(`${JSON.stringify({ dataId, status: "pending", hint: "Content not ready yet, retry in ~2 minutes" })}\n`);
241
+ }
182
242
  function addTimeFilters(command) {
183
243
  return command
184
244
  .option("--from <number>", "Starting offset", "0")
@@ -424,7 +484,6 @@ ai.command("peer-comparison").requiredOption("--security-code <code>").option("-
424
484
  });
425
485
  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
486
  const client = await createClient();
427
- // Step 1: get dataId
428
487
  const idResult = await client.call("ai.earnings-review.get-id", { securityCode: options.securityCode, period: options.period });
429
488
  const dataId = idResult?.dataId;
430
489
  if (!dataId) {
@@ -432,56 +491,20 @@ ai.command("earnings-review").requiredOption("--security-code <code>").requiredO
432
491
  process.exitCode = 1;
433
492
  return;
434
493
  }
435
- // Non-blocking: return dataId immediately
436
494
  if (!options.wait) {
437
495
  process.stderr.write(`Earnings review task submitted. dataId: ${dataId}\n`);
438
496
  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
497
  return;
440
498
  }
441
- // Blocking (--wait): poll for content
442
499
  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
- }
500
+ if (!await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, parseFormat(options.format), options.output)) {
501
+ process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
502
+ process.exitCode = 1;
464
503
  }
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
504
  });
468
505
  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
506
  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
- }
507
+ await checkAsyncContent(client, "ai.earnings-review.get-content", options.dataId, parseFormat(options.format), options.output);
485
508
  });
486
509
  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
510
  const client = await createClient();
@@ -492,7 +515,7 @@ ai.command("research-outline").requiredOption("--security-code <code>").option("
492
515
  const client = await createClient();
493
516
  await printData(await client.call("ai.research-outline", { securityCode: options.securityCode }), parseFormat(options.format), options.output);
494
517
  });
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) => {
518
+ 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
519
  const client = await createClient();
497
520
  const ALL_CATEGORIES = ["morningBriefing", "noonBriefing", "afternoonFlash", "eveningBriefing"];
498
521
  await printData(await client.call("ai.hot-topic", {
@@ -501,8 +524,8 @@ ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option
501
524
  startDate: options.startDate,
502
525
  endDate: options.endDate,
503
526
  categoryList: options.category.length > 0 ? options.category : ALL_CATEGORIES,
504
- withRelatedSecurities: options.withRelatedSecurities || undefined,
505
- withCloseReading: options.withCloseReading || undefined,
527
+ withRelatedSecurities: options.withRelatedSecurities === false ? undefined : true,
528
+ withCloseReading: options.withCloseReading === false ? undefined : true,
506
529
  }), parseFormat(options.format), options.output);
507
530
  });
508
531
  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 +546,6 @@ ai.command("management-discuss-earnings-call").requiredOption("--report-date <da
523
546
  });
524
547
  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
548
  const client = await createClient();
526
- // Step 1: get dataId
527
549
  const idResult = await client.call("ai.viewpoint-debate.get-id", { viewpoint: options.viewpoint });
528
550
  const dataId = idResult?.dataId;
529
551
  if (!dataId) {
@@ -531,56 +553,20 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
531
553
  process.exitCode = 1;
532
554
  return;
533
555
  }
534
- // Non-blocking: return dataId immediately
535
556
  if (!options.wait) {
536
557
  process.stderr.write(`Viewpoint debate task submitted. dataId: ${dataId}\n`);
537
558
  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
559
  return;
539
560
  }
540
- // Blocking (--wait): poll for content
541
561
  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
- }
562
+ if (!await pollAsyncContent(client, "ai.viewpoint-debate.get-content", dataId, parseFormat(options.format), options.output)) {
563
+ process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
564
+ process.exitCode = 1;
563
565
  }
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
566
  });
567
567
  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
568
  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
- }
569
+ await checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseFormat(options.format), options.output);
584
570
  });
585
571
  const vault = new Command("vault").description("Vault APIs");
586
572
  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 +603,15 @@ program.addCommand(vault);
617
603
  program.addCommand(ai);
618
604
  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
605
  const client = await createClient();
620
- const body = options.body ? JSON.parse(options.body) : undefined;
606
+ let body;
607
+ if (options.body) {
608
+ try {
609
+ body = JSON.parse(options.body);
610
+ }
611
+ catch {
612
+ throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
613
+ }
614
+ }
621
615
  const data = await client.call(endpointKey, body, options.query);
622
616
  if (data && typeof data === "object" && "data" in data && data.data instanceof Uint8Array) {
623
617
  await saveDownloadResult(data, "download.bin", options.output);
@@ -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.4";
2
+ export const CLI_VERSION = "0.10.6";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {