gangtise-openapi-cli 0.34.0 → 0.35.0

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/dist/src/cli.js CHANGED
@@ -14,44 +14,19 @@ import { fetchFileParseResult, pollFileParseResult, submitFileParse } from "./co
14
14
  import { normalizeRows, zipFieldRow } from "./core/normalize.js";
15
15
  import { parseOutputFormat } from "./core/output.js";
16
16
  import { printData } from "./core/printer.js";
17
- // --- Lazy-loaded modules (deferred to action handlers) ---
18
17
  async function createClient() {
19
18
  const { GangtiseClient } = await import("./core/client.js");
20
19
  return new GangtiseClient(loadConfig());
21
20
  }
22
- /**
23
- * Acquire a client, run `produce` to fetch data, and render it through the
24
- * shared pipeline. Collapses the `createClient()` + `printData(await client.call(...),
25
- * parseOutputFormat(options.format), options.output)` boilerplate that every
26
- * query command repeated.
27
- */
28
21
  async function emit(options, produce, cache) {
29
- // Validate --format before fetching: a typo'd format must not burn a full
30
- // (possibly credit-metered) data pull only to fail at render time.
31
22
  const format = parseOutputFormat(options.format);
32
23
  const client = await createClient();
33
24
  await printData(await produce(client), format, options.output, cache);
34
25
  }
35
- /** Acquire a client and run an arbitrary action (downloads, polling, custom shaping). */
36
26
  async function withClient(fn) {
37
27
  await fn(await createClient());
38
28
  }
39
- /**
40
- * Server-side default row cap shared by the limit-capped, non-paginated quote endpoints
41
- * (fund-flow, minute-kline, day/index kline — all default to 6000 per the API docs). We
42
- * send it EXPLICITLY when `--limit` is omitted (rather than letting the server apply its
43
- * own default) so the request limit and the truncation `cap` below are always the same
44
- * number — never a guess about the server's default that can drift out of sync.
45
- */
46
29
  const DEFAULT_QUOTE_LIMIT = 6000;
47
- /**
48
- * Limit-capped, non-paginated endpoints (fund-flow, kline) report `total` as the
49
- * RETURNED row count, not the true total, so a full page (rows == the limit we sent) is
50
- * the only truncation signal. Flag the result partial (printData → exit 3) + warn so a
51
- * capped export isn't mistaken for the full set. `cap` MUST be the exact limit the caller
52
- * sent on the request; `--limit` is validated to <= 10000 so `cap` can't exceed the
53
- * server ceiling and hide a truncation.
54
- */
55
30
  function flagIfLimitTruncated(data, cap, label) {
56
31
  if (!data || typeof data !== "object" || Array.isArray(data))
57
32
  return;
@@ -63,11 +38,6 @@ function flagIfLimitTruncated(data, cap, label) {
63
38
  process.stderr.write(`[gangtise] warning: ${label} returned ${rec.list.length} rows = the ${cap}-row limit; results are likely truncated (this endpoint has no pagination). Narrow --start-date/--end-date or raise --limit (max 10000), fetching in date batches.\n`);
64
39
  }
65
40
  }
66
- /**
67
- * Run a download. If `output` is set we already know the destination, so the
68
- * client streams the body straight to disk (no in-memory Uint8Array copy);
69
- * otherwise we buffer and let the caller resolve a friendly title.
70
- */
71
41
  async function runDownload(client, endpointKey, query, options) {
72
42
  if (options.output) {
73
43
  const result = await client.call(endpointKey, options.body, query, { streamTo: options.output });
@@ -76,15 +46,8 @@ async function runDownload(client, endpointKey, query, options) {
76
46
  }
77
47
  const result = await client.call(endpointKey, options.body, query);
78
48
  const resolved = options.resolveOutputPath ? await options.resolveOutputPath(result) : undefined;
79
- // Title-derived names are auto-generated too — dedupe them like the fallback names.
80
49
  await saveDownloadResult(result, options.fallbackName, resolved ? await uniquePath(resolved) : undefined);
81
50
  }
82
- /**
83
- * Register a download subcommand. All download commands share one shape: a
84
- * required id option, optionally --file-type / --content-type, then --output.
85
- * `idField` doubles as the commander option key and the query/title-cache
86
- * field, so it must stay the camelCase twin of `idOption`.
87
- */
88
51
  function addDownloadCommand(parent, spec) {
89
52
  const cmd = parent.command(spec.name ?? "download").requiredOption(`${spec.idOption} <id>`);
90
53
  if (spec.fileType) {
@@ -187,17 +150,6 @@ addTimeFilters(summary.command("list").addOption(new Option("--search-type <numb
187
150
  categoryList: maybeArray(options.category), marketList: maybeArray(options.market), participantRoleList: maybeArray(options.participantRole),
188
151
  }), { endpointKey: "insight.summary.list", idField: "summaryId" }));
189
152
  addDownloadCommand(summary, { endpointKey: "insight.summary.download", idOption: "--summary-id", idField: "summaryId", fallbackPrefix: "summary", fileType: { description: "File type: 1=original(default) 2=HTML; only affects meeting platform summaries", choices: ["1", "2"] }, titleListEndpoint: "insight.summary.list" });
190
- // Pamirs is one lead institution's expert-summary library, exposed on its own
191
- // path rather than as a `summary list` filter. It advertises a NARROWER filter
192
- // set than `summary` — no --source / --institution / --participant-role — so the
193
- // options are spelled out here instead of sharing summary's builder: an
194
- // unsupported flag would be dropped server-side and silently widen the result.
195
- // Every enum is whitelisted locally. The server drops an unrecognised VALUE the
196
- // same way it drops an unrecognised FIELD — silently, returning the unfiltered
197
- // set with exit 0. Worse, a bad `--search-type` takes `--keyword` down with it:
198
- // `--keyword 茅台 --search-type 99` answers 2963 (the whole library) instead of 2,
199
- // so the caller reads a full-library dump as a keyword hit. Same class of defect
200
- // that put whitelists on securities-search / institution-search / official-account.
201
153
  const PAMIRS_CATEGORIES = ["companyAnalysis", "industryAnalysis"];
202
154
  const PAMIRS_MARKETS = ["aShares", "hkStocks", "usChinaConcept", "usStocks"];
203
155
  addTimeFilters(pamirsSummary.command("list").description("List Pamirs expert summaries (requires the expert-summary database)").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--research-area <id>", "Research area ID: accepts BOTH industry code sets -- citicIndustry (1008001xx) and swIndustry (104xx0000) -- but NOT gangtiseIndustry direction codes (122000xxx), which return 0 on this endpoint only", collectList, []).option("--security <code>", "Security code, e.g. 000001.SZ", collectList, []).option("--category <name>", `Category: ${PAMIRS_CATEGORIES.join(" / ")}`, collectList, []).option("--market <name>", `Market: ${PAMIRS_MARKETS.join(" / ")}`, collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.pamirs-summary.list", {
@@ -258,20 +210,7 @@ addScheduleList(siteVisit, "insight.site-visit.list", {
258
210
  });
259
211
  addScheduleList(strategy, "insight.strategy.list", { institution: true, location: true });
260
212
  addScheduleList(forum, "insight.forum.list", { researchArea: true, location: true });
261
- // Earnings calendar: the only insight list filtered by DATE (--start-date/--end-date
262
- // on publishDate), not by the --start-time datetime every sibling uses — so it does
263
- // not go through addTimeFilters. It also takes no --keyword / --rank-type / --search-type.
264
- /** Row ceiling applied when `--security` is the only thing bounding a
265
- * performance-calendar fetch. Far above any single company's calendar (a whole
266
- * A-share history is dozens of rows), far below the 50k the auto-pagination
267
- * would otherwise pull if the server ever stopped honoring securityList. */
268
213
  const SECURITY_ONLY_ROW_CAP = 1000;
269
- /** Warn + mark partial when a `--security`-only fetch lands on the cap with rows
270
- * still unfetched: that is the signature of a filter that did not narrow anything,
271
- * and the rows on screen are then a truncated slice of the whole calendar rather
272
- * than a company's. `total` decides it — a result that happens to be exactly `cap`
273
- * rows long IS complete (from + rows covers total) and must stay exit 0, or every
274
- * automated caller reads a full answer as truncated. */
275
214
  function flagIfImplicitCapHit(data, cap, from) {
276
215
  if (!data || typeof data !== "object" || Array.isArray(data))
277
216
  return;
@@ -296,25 +235,12 @@ performanceCalendar.command("list").description("Earnings calendar (业绩预告
296
235
  .option("--category <name>", `Event type: ${PERFORMANCE_CATEGORIES.join("/")}`, collectList, [])
297
236
  .option("--format <format>", "Output format", "table").option("--output <path>", "Output path")
298
237
  .action((options) => {
299
- // Enum typos first: a misspelled --category is the likelier mistake, and its
300
- // message is the more useful one when both checks would fire.
301
238
  const marketList = parseChoiceList(options.market, "--market", PERFORMANCE_MARKETS);
302
239
  const categoryList = parseChoiceList(options.category, "--category", PERFORMANCE_CATEGORIES);
303
- // Unfiltered, this endpoint holds >120k rows (probed 2026-07-25: 126683, it
304
- // also carries FUTURE scheduled events) and an omitted --size means "fetch
305
- // everything" — 50k rows at the 1000-page cap, ~5000 credits at 0.1/row.
306
- // Require a bound: a full date range, an explicit --size, or a security filter.
307
240
  const explicitlyBounded = Boolean(options.size) || Boolean(options.startDate && options.endDate);
308
241
  if (!explicitlyBounded && !options.security.length) {
309
242
  throw new ValidationError("insight performance-calendar list without a bound would auto-paginate the whole calendar (>120k rows at 0.1 credits each): pass --start-date and --end-date, or --security, or an explicit --size");
310
243
  }
311
- // --security is only a real bound while the server honors securityList. It does
312
- // today (probed 2026-07-25: an unknown or malformed code returns total 0, it is
313
- // not silently ignored like a bad enum) — but a five-figure credit bill must not
314
- // rest on that staying true. When --security is the ONLY bound, cap the fetch:
315
- // one company's whole calendar is dozens of rows, so the cap is invisible in
316
- // normal use and turns a filter regression into a truncated result (partial +
317
- // exit 3) instead of a 5000-credit pull.
318
244
  const implicitCap = explicitlyBounded ? undefined : SECURITY_ONLY_ROW_CAP;
319
245
  const from = parseFrom(options.from);
320
246
  return emit(options, async (client) => {
@@ -349,12 +275,6 @@ addTimeFilters(foreignReport.command("list").addOption(new Option("--search-type
349
275
  minReportPages: parseOptionalNumberOption(options.minPages, "--min-pages", { integer: true, min: 0 }), maxReportPages: parseOptionalNumberOption(options.maxPages, "--max-pages", { integer: true, min: 0 }),
350
276
  }), { endpointKey: "insight.foreign-report.list", idField: "reportId" }));
351
277
  addDownloadCommand(foreignReport, { endpointKey: "insight.foreign-report.download", idOption: "--report-id", idField: "reportId", fallbackPrefix: "foreign-report", fileType: { description: "File type: 1=PDF 2=Markdown 3=CN-PDF 4=CN-Markdown", choices: ["1", "2", "3", "4"], default: "1" }, titleListEndpoint: "insight.foreign-report.list" });
352
- // Contract: A-share announcement startTime/endTime go out as 13-digit epoch millis
353
- // (parseTimestamp13), while HK/US announcement and every other insight list send the
354
- // datetime string straight through. All three filter correctly — verified live against
355
- // a narrow past window (each returns in-window rows). A-share's API also accepts the
356
- // string form, but the 13-digit conversion is kept as the historical spec contract;
357
- // don't "unify" it away without re-confirming the A-share announcement spec.
358
278
  addTimeFilters(announcement.command("list").addOption(new Option("--search-type <number>", "Search type: 1=title 2=fulltext").choices(["1", "2"]).default("1")).addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code", collectList, []).option("--category <id>", "Category ID", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.announcement.list", {
359
279
  from: parseFrom(options.from), size: parseSize(options.size),
360
280
  startTime: parseTimestamp13(options.startTime, "--start-time"), endTime: parseTimestamp13(options.endTime, "--end-time"),
@@ -380,7 +300,7 @@ addTimeFilters(announcementUs.command("list").addOption(new Option("--search-typ
380
300
  securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
381
301
  }), { endpointKey: "insight.announcement-us.list", idField: "announcementId" }));
382
302
  addDownloadCommand(announcementUs, { endpointKey: "insight.announcement-us.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-us", fileType: { description: "File type: 1=original PDF 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.announcement-us.list" });
383
- addTimeFilters(foreignOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code -- UNRELIABLE on this endpoint: cn/us/jp yield a null payload (no error); only gl returns rows. Filter on the region field client-side", collectList, []).option("--industry <id>", "Industry ID -- NOT effective on this endpoint: any value yields a null payload (not an empty list), with no error. Omit it and filter on industryList[] client-side", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-opinion.list", {
303
+ addTimeFilters(foreignOpinion.command("list").addOption(new Option("--rank-type <number>", "Rank type: 1=composite 2=time desc").choices(["1", "2"]).default("1")).option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code -- only 'gl' returns rows on this endpoint; cn/us/jp come back as a null payload with no error. Filter on the region field client-side", collectList, []).option("--industry <id>", "Industry ID -- NOT effective on this endpoint: any value yields a null payload (not an empty list), with no error. Omit it and filter on industryList[] client-side", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-opinion.list", {
384
304
  from: parseFrom(options.from), size: parseSize(options.size),
385
305
  startTime: options.startTime, endTime: options.endTime,
386
306
  rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
@@ -408,8 +328,6 @@ addTimeFilters(officialAccount.command("list").addOption(new Option("--search-ty
408
328
  categoryList: maybeArray(options.category), industryList: maybeArray(options.industry),
409
329
  }), { endpointKey: "insight.official-account.list", idField: "articleId" }));
410
330
  addDownloadCommand(officialAccount, { endpointKey: "insight.official-account.download", idOption: "--article-id", idField: "articleId", fallbackPrefix: "official-account", fileType: { description: "File type: 1=txt(default) 2=HTML", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.official-account.list" });
411
- // QA request keys are BARE (source/questionCategory/answerImportant), not the *List
412
- // convention — the body below mirrors the spec exactly. Datetimes pass through as strings.
413
331
  qa.command("list").requiredOption("--security-code <code>", "Security code, e.g. 601012.SH").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all (max page 500)").option("--start-time <datetime>", "Start time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)", datetimeArg("--start-time")).option("--end-time <datetime>", "End time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)", datetimeArg("--end-time")).option("--source <type>", "Source: conference/interactive/survey (repeat)", collectList, []).option("--question-category <name>", "Question category (repeat): productAndBusiness/capacityAndProjects/ordersAndCustomers/financialData/materialEvents/capitalOperations/shareholdersAndDividends/corporateGovernance/marketAndValuation/macroAndIndustry/risksAndOthers", collectList, []).option("--answer-important <flag>", "Answer involves key info: 1=yes 0=no (repeat; omit for all)", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("insight.qa.list", {
414
332
  from: parseFrom(options.from), size: parseSize(options.size),
415
333
  securityCode: options.securityCode, startTime: options.startTime, endTime: options.endTime,
@@ -443,66 +361,24 @@ program.addCommand(insight);
443
361
  const quote = new Command("quote").description("Quote APIs");
444
362
  const KLINE_MARKETS = { aShares: 1, hkStocks: 2, usStocks: 1 };
445
363
  const LEGACY_ALL_MARKET = (shardDays) => ({ all: shardDays });
446
- /** Realtime takes the same keywords but returns one snapshot per security, so there is
447
- * nothing to shard and it only needs the accepted-keyword list. */
448
364
  const REALTIME_MARKETS = ["aShares", "hkStocks", "usStocks"];
449
- /** fund-flow is A-share only, so `aShares` is its sole whole-market keyword. */
450
365
  const FUND_FLOW_MARKETS = ["aShares"];
451
- /** Every keyword the quote APIs have ever taken, including ones a given command no
452
- * longer accepts. What an unrecognised keyword does depends on the endpoint, and BOTH
453
- * outcomes are worth a local error: the unified `day-kline` / `realtime` / `fund-flow`
454
- * answer `120001` "invalid security code" (which sends the user hunting for a typo in a
455
- * code that is fine), while the menu-retired per-market endpoints answer `code=000000`
456
- * with `total: 0` — a silent empty result indistinguishable from "no data".
457
- *
458
- * Compared lower-cased, and 🔴 **that folding is load-bearing, not tidiness** — the API's
459
- * own case handling differs BY ENDPOINT (probed 2026-08-15, all six):
460
- *
461
- * folds case: day-kline (aShares/…), realtime, day-kline-hk/-us/index (all)
462
- * case-SENSITIVE: fund-flow — only the literal `aShares` works; `ashares` / `ASHARES`
463
- * come back as `120001 非有效A股`
464
- *
465
- * So on five endpoints canonicalising merely keeps our shard lookup in step with the
466
- * server (drop it and a case variant degrades to an unsharded 6000-row request), but on
467
- * `fund-flow` it is the ONLY reason `--security ashares` works at all. Removing
468
- * `canonicalizeMarketKeywords` would turn that from a working query into a hard error —
469
- * see the fund-flow case test, which exists to pin exactly that.
470
- *
471
- * ⚠️ `all` collides with a real ticker root (`ALL` is Allstate on the NYSE), so a bare
472
- * `--security ALL` fetches the whole US market instead of that stock. That resolution
473
- * happens on the SERVER (`ALL` / `All` / `all` are equivalent to it on all three retired
474
- * endpoints), so matching case-sensitively here would not prevent it — it would only stop
475
- * us from sharding a request the server treats as whole-market anyway, turning a complete
476
- * result into a 6000-row truncation. The fix for that user is the suffixed `ALL.N`.
477
- *
478
- * Unknown keywords are deliberately NOT rejected — this is a known-keyword list, so a
479
- * future server-side addition degrades to "unsharded" rather than being refused outright
480
- * (fail-open, no enum drift). */
481
366
  const MARKET_KEYWORDS = new Set(["all", "ashares", "hkstocks", "usstocks"]);
482
367
  const matchesMarketKeyword = (value, keyword) => value.toLowerCase() === keyword.toLowerCase();
483
368
  const checkMarketKeywords = (securities, accepted, command) => {
484
369
  const used = securities.filter((s) => MARKET_KEYWORDS.has(s.toLowerCase()));
485
370
  if (used.length === 0)
486
371
  return;
487
- // Report an unsupported keyword before the alone-ness rule: when both are wrong, the
488
- // keyword itself is the thing the user has to change.
489
372
  const unsupported = used.filter((k) => !accepted.some((a) => matchesMarketKeyword(k, a)));
490
373
  if (unsupported.length > 0) {
491
374
  throw new ValidationError(accepted.length > 0
492
375
  ? `${command}: '${unsupported[0]}' is not a whole-market keyword for this command — use ${accepted.join(" / ")}`
493
376
  : `${command}: this command takes explicit security codes only — '${unsupported[0]}' and other whole-market keywords are not supported`);
494
377
  }
495
- // The API rejects a keyword sent alongside security codes or a second keyword, again
496
- // as a bare 120001 that points at the codes rather than at the combination. On
497
- // `fund-flow` it is worse than a rejection: the keyword is silently dropped and only
498
- // the explicit codes come back, exit 0.
499
378
  if (securities.length > 1) {
500
379
  throw new ValidationError(`${command}: a market keyword must be passed alone, got '${securities.join(", ")}' — the API rejects it mixed with security codes or another keyword`);
501
380
  }
502
381
  };
503
- /** Fold a user-typed keyword back to the spelling the sharding lookup expects, so a case
504
- * variant reaches the same code path as the canonical form. Non-keywords pass through
505
- * untouched. */
506
382
  const canonicalizeMarketKeywords = (securities, accepted) => securities.map((s) => accepted.find((a) => matchesMarketKeyword(s, a)) ?? s);
507
383
  const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.command(name)
508
384
  .option("--security <code>", securityHelp, collectList, [])
@@ -513,30 +389,18 @@ const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.comm
513
389
  .option("--format <format>", "Output format", "table")
514
390
  .option("--output <path>")
515
391
  .action((options) => {
516
- // Validate BEFORE withClient: createClient() logs in when no token is cached, so a
517
- // check inside the callback would spend a request to then fail locally anyway.
518
392
  checkMarketKeywords(options.security, Object.keys(markets), `quote ${name}`);
519
393
  options.security = canonicalizeMarketKeywords(options.security, Object.keys(markets));
520
394
  return withClient(async (client) => {
521
395
  const format = parseOutputFormat(options.format);
522
396
  const body = buildQuoteKlineBody(options);
523
- // Each market shards at its own granularity, so resolve which keyword was asked
524
- // for before picking shardDays — a whole-market HK pull tolerates 2-day windows
525
- // where A-share and US pulls need one day each.
526
397
  const keyword = Object.keys(markets).find((k) => isFullMarket(body, k));
527
398
  if (keyword) {
528
- // A whole-market query is date-sharded: callKlineWithSharding lifts the limit to
529
- // the API max and owns completeness (partial / failedShards), so leave `limit`
530
- // unset and skip the single-request truncation guard.
531
399
  const data = await callKlineWithSharding(client, endpointKey, body, { shardDays: markets[keyword], fullMarketValue: keyword });
532
400
  await printData(data, format, options.output);
533
401
  return;
534
402
  }
535
- // Explicit securities go out as one request: pin the limit to the known default so
536
- // the sent limit and the truncation cap are the same number by construction.
537
403
  const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
538
- // (Nothing to shard: sharding only ever applied to whole-market queries, so this
539
- // is exactly the passthrough callKlineWithSharding would have done.)
540
404
  const data = await client.call(endpointKey, { ...body, limit });
541
405
  flagIfLimitTruncated(data, limit, name);
542
406
  await printData(data, format, options.output);
@@ -545,10 +409,6 @@ const addKlineCommand = (name, endpointKey, securityHelp, markets) => quote.comm
545
409
  addKlineCommand("day-kline", "quote.day-kline", "Security code — A-share .SH/.SZ/.BJ, HK .HK, US .O/.N/.A, exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI; or one market keyword: aShares / hkStocks / usStocks (auto-sharded by date, must be passed alone)", KLINE_MARKETS);
546
410
  addKlineCommand("day-kline-hk", "quote.day-kline-hk", "[deprecated: use 'day-kline'] Security code (HK stock: .HK, or 'all' for full market)", LEGACY_ALL_MARKET(2));
547
411
  addKlineCommand("day-kline-us", "quote.day-kline-us", "[deprecated: use 'day-kline'] Security code (US stock: e.g. AAPL.O, or 'all' for full market)", LEGACY_ALL_MARKET(1));
548
- // 15 days, not the historical 30: ~531 index rows per trading day x ~11 trading days in a
549
- // 15-day window is ~5.8K, while a 30-day window is ~11.7K — every shard silently maxed out
550
- // at the 10K cap and lost ~11% of the range (it surfaced as exit 3 + truncatedShards, but
551
- // the split was never sized to avoid it in the first place).
552
412
  addKlineCommand("index-day-kline", "quote.index-day-kline", "[deprecated: use 'day-kline'] Index code (.SH/.SZ/.BJ, or 'all' for full market)", LEGACY_ALL_MARKET(15));
553
413
  quote.command("minute-kline").option("--security <code>", "Security code — A-share .SH/.SZ (SH/SZ only), exchange index .SH/.SZ, concept index .GT, industry index .CI/.SWI; no whole-market keyword").option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss)", datetimeArg("--start-time")).option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss)", datetimeArg("--end-time")).option("--limit <number>", "Max rows per request (default: 6000, max: 10000)").option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
554
414
  const format = parseOutputFormat(options.format);
@@ -558,17 +418,10 @@ quote.command("minute-kline").option("--security <code>", "Security code — A-s
558
418
  await printData(data, format, options.output);
559
419
  }));
560
420
  quote.command("realtime").description("Realtime quote snapshot (A-share / HK / US stocks and indices)").option("--security <code>", "Security code — stock .SH/.SZ/.BJ/.HK/.O/.N/.A, exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI; or one market keyword: aShares / hkStocks / usStocks (must be passed alone; indices have no whole-market keyword)", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
561
- // Realtime takes the same market keywords as day-kline but never shards (one snapshot
562
- // per security), so it only needs the "alone, and a keyword this API knows" check.
563
421
  checkMarketKeywords(options.security, REALTIME_MARKETS, "quote realtime");
564
422
  return emit(options, (client) => client.call("quote.realtime", { securityList: maybeArray(options.security), fieldList: maybeArray(options.field) }));
565
423
  });
566
424
  quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").option("--security <code>", "Security code (e.g. 600519.SH / 872931.BJ), or 'aShares' for full A-share market — auto-sharded by day (repeat)", collectList, []).option("--start-date <date>", "Start date yyyy-MM-dd (default: endDate minus 1 year)", dateArg("--start-date")).option("--end-date <date>", "End date yyyy-MM-dd (default: latest trading day)", dateArg("--end-date")).option("--limit <number>", "Max rows per request (default: 6000, max: 10000; single-security cap — aShares auto-shards by day)").option("--field <field>", "Field, e.g. mainNetInflow/largeInflow/xlargeOutflow (repeat); omit for all", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
567
- // fund-flow needs this guard MORE than the kline commands, not less: mixing the keyword
568
- // with codes doesn't even fail here. The server silently drops `aShares` and answers
569
- // with just the explicit codes — one row, exit 0, no warning — so "whole market plus
570
- // this one" quietly becomes "only this one". Validate before withClient so no login
571
- // request is spent on a query that fails locally.
572
425
  checkMarketKeywords(options.security, FUND_FLOW_MARKETS, "quote fund-flow");
573
426
  options.security = canonicalizeMarketKeywords(options.security, FUND_FLOW_MARKETS);
574
427
  return withClient(async (client) => {
@@ -581,11 +434,6 @@ quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").opt
581
434
  fieldList: maybeArray(options.field),
582
435
  };
583
436
  if (isFullMarket(body, "aShares")) {
584
- // Full-market fund-flow: the server errors (430012/430013) instead of truncating when
585
- // a single request exceeds the row cap, so date-shard by day (~5.4k A-share rows/day,
586
- // under the lifted API cap) and merge — same mechanism as `--security all` kline.
587
- // Sharding needs an explicit range; without both dates it would fall back to one
588
- // doomed full-market request, so require the range up front with a clear message.
589
437
  if (!body.startDate || !body.endDate) {
590
438
  throw new ValidationError("quote fund-flow --security aShares requires both --start-date and --end-date (the full market is fetched via per-day shards)");
591
439
  }
@@ -637,8 +485,6 @@ fundamental.command("valuation-analysis").requiredOption("--security-code <code>
637
485
  fundamental.command("top-holders").requiredOption("--security-code <code>").addOption(new Option("--holder-type <type>", "Holder type: top10/top10Float").choices(["top10", "top10Float"]).makeOptionMandatory()).option("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).option("--fiscal-year <year>", "Fiscal year", collectList, []).option("--period <period>", "Period: q1/interim/q3/annual/latest", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("fundamental.top-holders", { securityCode: options.securityCode, holderType: options.holderType, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined })));
638
486
  fundamental.command("earning-forecast").requiredOption("--security-code <code>").option("--start-date <date>", "Start date (default: 1 year before end-date)", dateArg("--start-date")).option("--end-date <date>", "End date (default: today)", dateArg("--end-date")).option("--consensus <name>", "Consensus indicator: netIncome/netIncomeYoy/eps/pe/bps/pb/peg/roe/ps", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => {
639
487
  const endDate = options.endDate ?? localDateString(new Date());
640
- // Anchor the default window to endDate (as the help text promises), not to today —
641
- // a historical --end-date without --start-date should mean "the year before it".
642
488
  const startDate = options.startDate ?? new Date(new Date(`${endDate}T00:00:00Z`).getTime() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
643
489
  return client.call("fundamental.earning-forecast", { securityCode: options.securityCode, startDate, endDate, consensusList: maybeArray(options.consensus) });
644
490
  }));
@@ -676,8 +522,6 @@ ai.command("earnings-review").requiredOption("--security-code <code>").requiredO
676
522
  process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
677
523
  const outcome = await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, format, options.output);
678
524
  if (outcome !== "ok") {
679
- // "failed" already printed its terminal "Do not retry" line — only a timeout
680
- // gets the retry hint.
681
525
  if (outcome === "timeout") {
682
526
  process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
683
527
  }
@@ -737,12 +581,8 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
737
581
  }));
738
582
  ai.command("viewpoint-debate-check").requiredOption("--data-id <id>", "dataId from viewpoint-debate").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient((client) => checkAsyncContent(client, "ai.viewpoint-debate.get-content", options.dataId, parseOutputFormat(options.format), options.output)));
739
583
  ai.command("stock-summary").description("Stock highlights: refined research summary per security (A-share / HK)").option("--security <code>", "Security code (e.g. 600519.SH / 00700.HK), up to 6000 per call; market keywords are NOT supported by this endpoint", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
740
- // Guard against an empty --security: omitting it would send securityList:undefined,
741
- // which the backend may treat as all-market (3 credits/row × thousands of rows).
742
584
  if (!options.security.length)
743
585
  throw new ValidationError("--security is required: pass one or more security codes (A-share / HK), up to 6000 per call");
744
- // The endpoint dropped whole-market batches on 2026-08-14 and now answers a market
745
- // keyword with 120001 "invalid security code" — which reads as a typo in the code.
746
586
  checkMarketKeywords(options.security, [], "ai stock-summary");
747
587
  return emit(options, (client) => client.call("ai.stock-summary.list", { securityList: maybeArray(options.security) }));
748
588
  });
@@ -772,7 +612,6 @@ reference.command("institution-search").requiredOption("--keyword <text>", "Sear
772
612
  categoryList: parseChoiceList(options.category, "--category", ["domesticBroker", "foreignInstitution", "leadInstitution", "opinionInstitution", "foreignOpinionInstitution"]),
773
613
  top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
774
614
  })));
775
- // Note: request key is BARE `category` here (spec), unlike institution-search's `categoryList`.
776
615
  reference.command("official-account-search").requiredOption("--keyword <text>", "Search keyword (account name / institution / keyword, e.g. 东吴证券)").option("--category <name>", "Category: listedCompany/broker/government/media (repeat); omit for all incl. uncategorized", collectList, []).option("--top <number>", "Max results (default: 10, max: 10)", "10").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.official-account-search", {
777
616
  keyword: options.keyword,
778
617
  category: parseChoiceList(options.category, "--category", ["listedCompany", "broker", "government", "media"]),
@@ -815,29 +654,7 @@ alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (rep
815
654
  alternative.command("concept-info").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-info", { conceptId: options.conceptId })));
816
655
  alternative.command("concept-securities").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.concept-securities", { conceptId: options.conceptId })));
817
656
  program.addCommand(alternative);
818
- /** Mark and report the request codes the server did not answer for at all.
819
- *
820
- * What this catches changed on the server. EDE used to drop any axis it had no
821
- * DATA for — an indicator empty for every security vanished from
822
- * `indicatorList`, a security empty for every indicator vanished from
823
- * `securityCodeList`. Re-probed 2026-08-08: a coverage gap is now padded with
824
- * `null` and keeps its row and column (`mgn_bal` × 00700.HK, `finc_pb_mrq` ×
825
- * 09992.HK — both null, both present, even as the only cell in the request).
826
- *
827
- * A code the server cannot RESOLVE still vanishes: an unknown indicator code, or
828
- * a security code with the wrong suffix (`AAPL.US`, whose real form is
829
- * `AAPL.O`). So this is now a typo detector rather than a coverage detector —
830
- * which is the more useful of the two, since a coverage gap is visible as `null`
831
- * but a misspelled code is otherwise invisible: exit code 0, a plausible-looking
832
- * table, and a `--key-by code` mapping whose key simply is not there.
833
- *
834
- * Same signal as a failed page or a row cap: `partial` on the payload (printData
835
- * → exit 3) plus the omitted codes, so an automated caller can react without
836
- * parsing stderr. */
837
657
  function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
838
- // A wholly empty response is not a partial one: the diff against the request
839
- // would list everything as "omitted", which says nothing about which axis is
840
- // at fault. Exit 0, but say why it is ambiguous.
841
658
  if (isEmptyMatrix(data)) {
842
659
  process.stderr.write("[gangtise] note: the query returned no data at all. Since a real coverage gap now comes back as a null cell rather than an empty table, this usually means NOTHING in the request resolved — every security code or every indicator code was unrecognised — or a parameter name is wrong. Cross-check codes against 'gangtise indicator search --format json' and 'gangtise reference securities-search'.\n");
843
660
  return;
@@ -860,11 +677,6 @@ function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
860
677
  parts.push(`securities ${securities.join(", ")}`);
861
678
  process.stderr.write(`[gangtise] warning: the response omits ${parts.join(" and ")} entirely — no row/column at all, not a null one. A code the server merely has no data for still comes back as null, so this normally means the code itself was not recognised: check it for typos and, for securities, for the wrong market suffix (US tickers are .O/.N, not .US). Result marked partial (exit 3).\n`);
862
679
  }
863
- /** `--indicator` / `--security` are repeatable, so Commander cannot mark them
864
- * required — but every matrix endpoint needs at least one of each and answers a
865
- * missing one with 100001, whose hint sends the user to `--help`, which in turn
866
- * showed them as optional with a `[]` default. Catch it here: no request, no
867
- * round trip, and a message that names the flag. */
868
680
  function requireIndicatorScope(indicators, securities) {
869
681
  const missing = [indicators.length === 0 ? "--indicator" : "", securities.length === 0 ? "--security" : ""].filter(Boolean);
870
682
  if (missing.length > 0) {
@@ -880,13 +692,11 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
880
692
  });
881
693
  await printData(unwrapIndicatorData(raw), format, options.output);
882
694
  }));
883
- indicator.command("cross-section").option("--indicator <code>", "Indicator code, e.g. qte_close (REQUIRED, repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, []).requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as each indicator's tradeDate. Report-period indicators (is_*, financial statements) REJECT tradeDate and require --indicator-param 'code:reportDate=yyyy-MM-dd' instead — check parameterList in 'indicator search'", dateArg("--date")).option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustType=2 for 前复权 (repeat); read exact keys from 'indicator search'", collectList, []).addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode, unique & order-stable for batch code→value mapping").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
695
+ indicator.command("cross-section").option("--indicator <code>", "Indicator code, e.g. qte_close (REQUIRED, repeat for multiple)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, []).requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as each indicator's tradeDate. Report-period indicators (is_*, financial statements) REJECT tradeDate and require --indicator-param 'code:reportDate=yyyy-MM-dd' instead — check parameterList in 'indicator search'", dateArg("--date")).option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)").option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)").option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustType=2 for 前复权 (repeat); read exact keys from 'indicator search'. Bare 'code:' (nothing after the colon) declares the indicator takes NO date — required by any indicator whose parameterList has no date key at all, which otherwise rejects the tradeDate --date injects: the pty_* / scr_* static-attribute families (pty_op_scope, scr_exchg_mkt, scr_isin …), plus div_cash_paid_ratio / div_cash_yr (add 'code:fiscalYear=YYYY' too) and pty_shr_reg. It composes with real params, so 'code:' + 'code:scale=8' keeps the scale", collectList, []).addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode, unique & order-stable for batch code→value mapping").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
884
696
  const format = parseOutputFormat(options.format);
885
697
  requireIndicatorScope(options.indicator, options.security);
886
698
  const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
887
699
  const data = requireIndicatorMatrix(raw);
888
- // Flatten first: a shape error must not be preceded by a dropped-rows warning
889
- // that reads like the run merely came back short.
890
700
  const rows = flattenCrossSection(data, options.keyBy);
891
701
  flagDropped(rows, data, options.security, options.indicator);
892
702
  await printData(rows, format, options.output);
@@ -896,56 +706,23 @@ indicator.command("time-series").option("--indicator <code>", "Indicator code, e
896
706
  requireIndicatorScope(options.indicator, options.security);
897
707
  const raw = await client.call("indicator.time-series", buildIndicatorTimeSeriesBody(options));
898
708
  const data = requireIndicatorMatrix(raw);
899
- // Pass the universe itself, not a count: flattenTimeSeries needs to know
900
- // whether a sector ID is in play (the server expands it, so one entry can mean
901
- // many securities) and dedupes internally. The request only breaks the tie when
902
- // the response is 1×1 anyway. Flatten before flagging so a shape error surfaces
903
- // on its own.
904
709
  const rows = flattenTimeSeries(data, options.keyBy, options.security);
905
710
  flagDropped(rows, data, options.security, options.indicator);
906
711
  await printData(rows, format, options.output);
907
712
  }));
908
- indicator.command("screener").description("Screen securities by an expression over indicator values (条件选股)").option("--indicator <spec>", "Bind a variable to an indicator, 'F1:code', e.g. F1:qte_mkt_cptl (REQUIRED, repeat)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, []).requiredOption("--expression <expr>", "Filter over the bound variables, e.g. 'F1 >= 800 && (F2 >= 20 && F2 <= 30)'; also supports contains/notcontains on string indicators").requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as every indicator's tradeDate unless it already has one — omitting it leaves date-bearing indicators unfiltered and silently yields an empty screen. Report-period indicators (is_*) reject tradeDate: give them --indicator-param 'F1:reportDate=yyyy-MM-dd'", dateArg("--date")).option("--indicator-param <spec>", "Per-variable param 'F1:key=value', e.g. F1:scale=8 (repeat); read exact keys from 'indicator search'", collectList, []).addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
713
+ indicator.command("screener").description("Screen securities by an expression over indicator values (条件选股)").option("--indicator <spec>", "Bind a variable to an indicator, 'F1:code', e.g. F1:qte_mkt_cptl (REQUIRED, repeat)", collectList, []).option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, []).requiredOption("--expression <expr>", "Filter over the bound variables, e.g. 'F1 >= 800 && (F2 >= 20 && F2 <= 30)'; also supports contains/notcontains on string indicators").requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as every indicator's tradeDate unless it already has one — omitting it leaves date-bearing indicators unfiltered and silently yields an empty screen. Report-period indicators (is_*) reject tradeDate: give them --indicator-param 'F1:reportDate=yyyy-MM-dd'", dateArg("--date")).option("--indicator-param <spec>", "Per-variable param 'F1:key=value', e.g. F1:scale=8 (repeat); read exact keys from 'indicator search'. The cross-section no-date opt-out ('F1:' with nothing after the colon) is REJECTED here: an indicator sent with no parameters does not take part in the screen and the query comes back as 0 rows with no error, so indicators whose parameterList has no date key (the pty_* / scr_* families, div_cash_yr, pty_shr_reg) cannot be screened on at all — read them with 'indicator cross-section' and filter client-side", collectList, []).addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
909
714
  const format = parseOutputFormat(options.format);
910
715
  requireIndicatorScope(options.indicator, options.security);
911
- // Binding one indicator code to several variables (the same price on two
912
- // dates) is supported: the server used to answer every such binding from the
913
- // EARLIEST date among them and null the rest, which made the whole result
914
- // untrustworthy and needed an `unreliable` flag. Re-probed 2026-08-08 — fixed:
915
- // each variable now carries its own date's value, stable across repeats.
916
716
  const bindings = parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression);
917
717
  const raw = await client.call("indicator.screener", buildIndicatorScreenerBody(options));
918
- // Same payload shape as cross-section (one row per matched security, one
919
- // column per indicator) with a `field` on each indicator entry. No dropped-row
920
- // flag here: a security missing from a screener result means it failed the
921
- // filter, which is the whole point.
922
718
  const data = requireIndicatorMatrix(raw);
923
- // Before anything is rendered: the returned bindings must be the ones that
924
- // were asked for, and the expression must still be evaluable from the columns
925
- // that came back — otherwise the rows cannot be shown to satisfy the
926
- // conditions they claim to. See checkScreenerBindings for how a missing column
927
- // is weighed against the expression's boolean structure.
928
- // Flatten first: it asserts the payload's structural axes, and a response
929
- // missing `indicatorList` outright deserves that diagnosis rather than being
930
- // reported as a binding problem. Nothing renders in between, so ordering the
931
- // structural check ahead of the semantic one is free.
932
719
  const rows = flattenCrossSection(data, options.keyBy);
933
720
  const filteredOn = screenerExpressionFields(options.expression);
934
721
  const unbound = checkScreenerBindings(data, bindings, options.expression);
935
- // Same ambiguity as the other matrix commands: an empty screen is a normal
936
- // answer AND what a wrong parameter name produces. Keyed on "nothing matched"
937
- // rather than the strict canonical-empty shape — a response that returns zero
938
- // securities while still echoing `indicatorList` is just as empty to the
939
- // caller, and just as ambiguous, but would slip past isEmptyMatrix.
940
- // flattenCrossSection above already asserted this is an array of non-empty
941
- // strings, so only its length is left to read.
942
722
  if (data.securityCodeList.length === 0) {
943
723
  process.stderr.write("[gangtise] note: nothing matched the expression. That is a normal answer — but an empty result is ALSO what an unrecognised code or a wrong parameter name produces. Cross-check the indicator codes and parameters against 'gangtise indicator search --format json'.\n");
944
724
  }
945
725
  if (unbound.length > 0) {
946
- // Whatever reached here still leaves the expression evaluable (or was never
947
- // read by it), so the rows stand: losing the column costs information, not
948
- // correctness, and it degrades rather than failing.
949
726
  const rec = rows;
950
727
  rec.partial = true;
951
728
  rec.omittedIndicators = unbound.map((field) => bindings.find((b) => b.field === field)?.indicatorCode ?? field);
@@ -972,8 +749,6 @@ tool.command("file-parse").description("Parse a PDF into Markdown + images (asyn
972
749
  }
973
750
  process.stderr.write(`Got taskId: ${taskId}, waiting for the parse to finish...\n`);
974
751
  if (await pollFileParseResult(client, taskId, options.output) !== "ok") {
975
- // Fetching the result is free and the task keeps running server-side —
976
- // re-checking later costs nothing, resubmitting re-bills the whole file.
977
752
  process.stderr.write(`Parse result not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise tool file-parse-check --task-id ${taskId}\n`);
978
753
  process.exitCode = 1;
979
754
  }
@@ -1003,11 +778,7 @@ program.command("raw").description("Raw API calls").addCommand(new Command("call
1003
778
  throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
1004
779
  }
1005
780
  }
1006
- // Fail loudly on arguments the endpoint kind can't use — they used to be
1007
- // silently dropped, leaving the user to puzzle over server-side errors.
1008
781
  if (endpoint.kind === "download") {
1009
- // POST download endpoints (file-parse result) take their parameters as a JSON
1010
- // body; GET ones take --query and can't carry a body at all.
1011
782
  if (body !== undefined && endpoint.method !== "POST") {
1012
783
  throw new ValidationError(`--body is not supported for GET download endpoints (use --query key=value); ${endpointKey} is kind=download`);
1013
784
  }
@@ -1028,29 +799,15 @@ async function checkForUpdate(timeoutMs = 2000) {
1028
799
  try {
1029
800
  const response = await fetch("https://registry.npmjs.org/gangtise-openapi-cli/latest", { signal: AbortSignal.timeout(timeoutMs) });
1030
801
  const latest = (await response.json()).version;
1031
- // Ordered compare, not inequality: during the just-published window the
1032
- // registry still serves the PREVIOUS version — don't suggest a "downgrade".
1033
802
  if (latest && isVersionNewer(latest, CLI_VERSION)) {
1034
803
  process.stderr.write(`Update available: ${CLI_VERSION} → ${latest}\nRun: npm update -g gangtise-openapi-cli\n`);
1035
804
  }
1036
805
  }
1037
- catch { /* best-effort: offline or a slow registry must not break --version */ }
806
+ catch { }
1038
807
  }
1039
- /** Last-resort reporting for anything that escapes main()'s try/catch: an error
1040
- * thrown inside an event callback or a rejected promise nobody awaited. Node's
1041
- * default is a multi-line crash dump on stdout/stderr AND a non-zero exit — so a
1042
- * command whose data was already printed correctly would end up looking like a
1043
- * hard failure, with a stack trace where a message belongs. This release is
1044
- * about exit codes meaning what they say; that is the one path that lies.
1045
- *
1046
- * The stack is kept behind --verbose: reaching here at all means a CLI bug
1047
- * rather than an API failure, and one line is not enough to locate one. */
1048
- /** Milliseconds to let already-queued stdout reach the pipe before leaving. */
1049
808
  const FATAL_FLUSH_MS = 200;
1050
809
  let leaving = false;
1051
810
  function reportFatal(error) {
1052
- // `error.stack` already opens with "Name: message" — printing both duplicates
1053
- // the first line.
1054
811
  const stack = error instanceof Error ? error.stack : undefined;
1055
812
  const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1056
813
  const text = `${isVerbose() && stack ? stack : message}\n`;
@@ -1060,17 +817,6 @@ function reportFatal(error) {
1060
817
  }
1061
818
  leaving = true;
1062
819
  process.exitCode = 1;
1063
- // Terminating is not optional: a fatal error with a live handle (a timer, an
1064
- // open socket) would otherwise keep the process running forever on `exitCode`
1065
- // alone. But `process.exit()` on the spot truncates whatever is still queued —
1066
- // to a pipe BOTH streams are written asynchronously — and a handler meant to
1067
- // stop this release from lying about exit codes must not start dropping output
1068
- // to do it. So wait for the diagnostic itself AND for anything stdout still
1069
- // owes, each bounded by the same deadline.
1070
- //
1071
- // stderr is not merely "check the length": the write is issued right here, so
1072
- // its callback is the only thing that knows when it actually reached the pipe.
1073
- // Exiting on the spot truncated a large diagnostic to one 64 KiB buffer.
1074
820
  let pending = 1;
1075
821
  const leave = () => { if (--pending === 0)
1076
822
  process.exit(1); };
@@ -1083,12 +829,6 @@ function reportFatal(error) {
1083
829
  }
1084
830
  process.on("uncaughtException", reportFatal);
1085
831
  process.on("unhandledRejection", reportFatal);
1086
- /** Teardown races on a closed stdout: the reader went away, which is not this
1087
- * process's failure. `gangtise ... | head` is the everyday case — it truncates
1088
- * the output and still exits 0, so a same-class race has no principled reason to
1089
- * exit 1. Without a handler the final write crashes Node with an unhandled
1090
- * 'error' event; rethrowing from this callback did the same by another route
1091
- * (a crash dump appended AFTER the correct JSON had already been written). */
1092
832
  const READER_GONE = new Set(["EPIPE", "ERR_STREAM_DESTROYED", "EBADF"]);
1093
833
  process.stdout.on("error", (error) => {
1094
834
  if (error?.code && READER_GONE.has(error.code))
@@ -1096,8 +836,6 @@ process.stdout.on("error", (error) => {
1096
836
  reportFatal(error);
1097
837
  });
1098
838
  async function main() {
1099
- // Positional check, not argv.includes: "--version" appearing later (e.g. as
1100
- // another option's value) must not short-circuit the whole command.
1101
839
  const firstArg = process.argv[2];
1102
840
  if (firstArg === "--version" || firstArg === "-V") {
1103
841
  process.stdout.write(`${CLI_VERSION}\n`);
@@ -1110,8 +848,6 @@ async function main() {
1110
848
  catch (error) {
1111
849
  if (error instanceof ApiError) {
1112
850
  const hint = error.hint ? ` ${error.hint}` : "";
1113
- // traceId is what Gangtise support needs to look a failure up; without it a
1114
- // 999999 report is unactionable on their side.
1115
851
  const trace = error.traceId ? ` [trace ${error.traceId}]` : "";
1116
852
  process.stderr.write(`API error${error.code ? ` (${error.code})` : ""}${trace}: ${error.message}${hint}\n`);
1117
853
  process.exitCode = 1;