gangtise-openapi-cli 0.10.4 → 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,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,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);
@@ -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.5";
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.5",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {