gangtise-openapi-cli 0.41.1 → 0.42.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
@@ -1,116 +1,22 @@
1
1
  #!/usr/bin/env node
2
- import { Command, Option } from "commander";
3
- import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/asyncContent.js";
4
- import { readTokenCache, redactTokenCache } from "./core/auth.js";
5
- import { collectKeyValue, collectList, collectNumberList, collectText, dateArg, datetimeArg, screenerExpressionFields, isVersionNewer, localDateString, maybeArray, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseScreenerIndicators, parseSize, parseTimestamp13 } from "./core/args.js";
6
- import { buildIndicatorCrossSectionBody, buildIndicatorScreenerBody, buildIndicatorTimeSeriesBody, buildQuoteKlineBody, buildStockPoolStocksBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
7
- import { checkScreenerBindings, droppedFromMatrix, flattenCrossSection, flattenTimeSeries, isEmptyMatrix, requireIndicatorMatrix, unwrapIndicatorData } from "./core/indicatorMatrix.js";
8
- import { callPerSecurity, estimateTradingDays } from "./core/perSecurity.js";
9
- import { callKlineWithSharding, isFullMarket } from "./core/quoteSharding.js";
10
- import { loadConfig } from "./core/config.js";
11
- import { releaseClaim, resolveTitle, saveDownloadResult, uniquePath } from "./core/download.js";
12
- import { resolveCalendarType } from "./core/calendarType.js";
13
- import { ENDPOINTS, listEndpoints } from "./core/endpoints.js";
14
- import { ApiError, ConfigError, ValidationError } from "./core/errors.js";
15
- import { uploadDriveFile } from "./core/driveUpload.js";
16
- import { fetchFileParseResult, pollFileParseResult, submitFileParse } from "./core/fileParse.js";
17
- import { assertColumnarHeader, flagFailedItems, flagMissingFields, normalizeRows, zipFieldRow } from "./core/normalize.js";
18
- import { parseOutputFormat } from "./core/output.js";
19
- import { printData } from "./core/printer.js";
20
- import { ExportSink, rowCount } from "./core/rowSink.js";
21
- async function createClient(stream) {
22
- const { GangtiseClient } = await import("./core/client.js");
23
- const sink = stream?.output && (stream.format === "jsonl" || stream.format === "csv") ? new ExportSink(stream.output, stream.format, stream.cache) : undefined;
24
- return new GangtiseClient(loadConfig(), sink);
25
- }
26
- async function emit(options, produce, cache) {
27
- const format = parseOutputFormat(options.format);
28
- const client = await createClient({ format, output: options.output, cache });
29
- try {
30
- await printData(await produce(client), format, options.output, cache);
31
- }
32
- finally {
33
- await client.rowSink?.abort();
34
- }
35
- }
36
- async function withClient(a, b) {
37
- const [stream, fn] = typeof a === "function" ? [undefined, a] : [a, b];
38
- const client = await createClient(stream);
39
- try {
40
- await fn(client);
41
- }
42
- finally {
43
- await client.rowSink?.abort();
44
- }
45
- }
46
- const DEFAULT_QUOTE_LIMIT = 6000;
47
- function flagIfLimitTruncated(data, cap, label, rangeFlags = "--start-date/--end-date", advice) {
48
- if (!data || typeof data !== "object" || Array.isArray(data))
49
- return;
50
- const rec = data;
51
- if (rec.partial === true)
52
- return;
53
- if (Array.isArray(rec.list) && rec.list.length >= cap) {
54
- rec.partial = true;
55
- process.stderr.write(`[gangtise] warning: ${label} returned ${rec.list.length} rows = the ${cap}-row limit; results are likely truncated (this endpoint has no pagination). ${advice ?? `Narrow ${rangeFlags} or raise --limit (max 10000), fetching in date batches.`}\n`);
56
- }
57
- }
58
- async function runDownload(client, endpointKey, query, options) {
59
- if (options.output) {
60
- const result = await client.call(endpointKey, options.body, query, { streamTo: options.output });
61
- await saveDownloadResult(result, options.fallbackName, options.output);
62
- return;
63
- }
64
- const result = await client.call(endpointKey, options.body, query);
65
- const resolved = options.resolveOutputPath ? await options.resolveOutputPath(result) : undefined;
66
- const target = resolved ? await uniquePath(resolved) : undefined;
67
- try {
68
- await saveDownloadResult(result, options.fallbackName, target);
69
- }
70
- catch (error) {
71
- if (target)
72
- await releaseClaim(target);
73
- throw error;
74
- }
75
- }
76
- function addDownloadCommand(parent, spec) {
77
- const cmd = parent.command(spec.name ?? "download").requiredOption(`${spec.idOption} <id>`);
78
- if (spec.fileType) {
79
- const option = new Option("--file-type <number>", spec.fileType.description).choices(spec.fileType.choices);
80
- cmd.addOption(spec.fileType.required ? option.makeOptionMandatory() : option.default(spec.fileType.default));
81
- }
82
- if (spec.contentTypeDescription)
83
- cmd.requiredOption("--content-type <type>", spec.contentTypeDescription);
84
- if (spec.titleListEndpoint) {
85
- cmd.option("--resolve-title", "On a title-cache miss, query the list endpoint for a friendly filename (4 extra requests; most of these list endpoints bill per row). Without it the server filename or <prefix>-<id> is used. Ignored when --output is given");
86
- }
87
- cmd.option("--output <path>").action((options) => withClient(async (client) => {
88
- const id = options[spec.idField];
89
- const qp = { [spec.idField]: id };
90
- if (spec.fileType && options.fileType)
91
- qp.fileType = parseNumberOption(options.fileType, "--file-type", { integer: true, min: 1 });
92
- if (spec.contentTypeDescription)
93
- qp.contentType = options.contentType;
94
- const titleList = spec.titleListEndpoint;
95
- await runDownload(client, spec.endpointKey, qp, {
96
- output: options.output,
97
- fallbackName: `${spec.fallbackPrefix}-${id}`,
98
- resolveOutputPath: titleList
99
- ? (result) => resolveTitle(client, result, titleList, spec.idField, id, { allowLookup: Boolean(options.resolveTitle) })
100
- : undefined,
101
- });
102
- }));
103
- }
104
- function addTimeFilters(command) {
105
- return command
106
- .option("--from <number>", "Starting offset", "0")
107
- .option("--size <number>", "Total rows to return; omit to fetch all")
108
- .option("--start-time <datetime>", "Start time", datetimeArg("--start-time"))
109
- .option("--end-time <datetime>", "End time", datetimeArg("--end-time"))
110
- .option("--keyword <keyword>", "Keyword");
111
- }
2
+ import { Command } from "commander";
3
+ import { isVersionNewer } from "./core/args.js";
4
+ import { ApiError } from "./core/errors.js";
112
5
  import { isVerbose, setVerbose } from "./core/transport.js";
113
6
  import { CLI_VERSION } from "./version.js";
7
+ import { ai } from "./commands/ai.js";
8
+ import { alternative } from "./commands/alternative.js";
9
+ import { auth, lookup } from "./commands/auth.js";
10
+ import { bond } from "./commands/bond.js";
11
+ import { fundamental } from "./commands/fundamental.js";
12
+ import { indicator } from "./commands/indicator.js";
13
+ import { insight } from "./commands/insight.js";
14
+ import { quote } from "./commands/quote.js";
15
+ import { raw } from "./commands/raw.js";
16
+ import { reference } from "./commands/reference.js";
17
+ import { tool } from "./commands/tool.js";
18
+ import { vault } from "./commands/vault.js";
19
+ import { decidedExitCode, markFailed } from "./core/exitStatus.js";
114
20
  const program = new Command();
115
21
  program
116
22
  .name("gangtise")
@@ -121,1044 +27,9 @@ program
121
27
  if (thisCommand.opts().verbose)
122
28
  setVerbose(true);
123
29
  });
124
- program
125
- .command("auth")
126
- .description("Authentication commands")
127
- .addCommand(new Command("login")
128
- .option("--show-token", "Show the raw access token (default: redacted)")
129
- .option("--format <format>", "Output format", "json")
130
- .action((options) => emit(options, async (client) => {
131
- const result = await client.login();
132
- const note = result.source === "env-token"
133
- ? "GANGTISE_TOKEN is set, so this is the token every command will send — no login was performed. Unset it to use GANGTISE_ACCESS_KEY / GANGTISE_SECRET_KEY instead."
134
- : undefined;
135
- const base = { source: result.source, ...(note ? { note } : {}) };
136
- return options.showToken
137
- ? { ...base, authorization: result.authorization, cache: result.cache }
138
- : { ...base, authorization: "<redacted>", cache: redactTokenCache(result.cache) };
139
- })))
140
- .addCommand(new Command("status")
141
- .option("--format <format>", "Output format", "json")
142
- .action(async (options) => {
143
- const config = loadConfig();
144
- const cache = await readTokenCache(config.tokenCachePath);
145
- await printData({ hasEnvToken: Boolean(config.token), hasCachedToken: Boolean(cache?.accessToken), cache: redactTokenCache(cache) }, parseOutputFormat(options.format));
146
- }));
147
- const lookup = new Command("lookup").description("Local lookup tables (IDs not covered by 'reference constant-list')");
148
- const addLookupList = (name, endpointKey, description) => {
149
- const cmd = new Command(name);
150
- if (description)
151
- cmd.description(description);
152
- lookup.addCommand(cmd.addCommand(new Command("list").option("--format <format>", "Output format", "table").action((options) => emit(options, (client) => client.call(endpointKey)))));
153
- };
154
- addLookupList("broker-org", "lookup.broker-orgs.list");
155
- addLookupList("meeting-org", "lookup.meeting-orgs.list");
156
- program.addCommand(lookup);
157
- const insight = new Command("insight").description("Insight APIs");
158
- const opinion = new Command("opinion");
159
- const summary = new Command("summary");
160
- const pamirsSummary = new Command("pamirs-summary");
161
- const roadshow = new Command("roadshow");
162
- const siteVisit = new Command("site-visit");
163
- const strategy = new Command("strategy");
164
- const forum = new Command("forum");
165
- const performanceCalendar = new Command("performance-calendar");
166
- const research = new Command("research");
167
- const foreignReport = new Command("foreign-report");
168
- const announcement = new Command("announcement");
169
- const announcementHk = new Command("announcement-hk");
170
- const announcementUs = new Command("announcement-us");
171
- const foreignOpinion = new Command("foreign-opinion");
172
- const independentOpinion = new Command("independent-opinion");
173
- const officialAccount = new Command("official-account");
174
- const qa = new Command("qa");
175
- const reportImage = new Command("report-image");
176
- addTimeFilters(opinion.command("list").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: citicIndustry code (1008001xx) or gangtiseIndustry direction code (122000xxx: macro/strategy/fixed-income/quant/overseas). swIndustry (104xx0000) returns 0 here", collectList, []).option("--chief <id>", "Chief ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--concept <id>", "Concept ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--source <source>", "Source", collectList, []).option("--with-content", "Return the full body inline (v1 list, 30 credits/row) instead of the 200-char brief (1 credit/row). v1 shape: title and body sit under contentList.title / contentList.content, and there is no brief").option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call(options.withContent ? "insight.opinion.list-with-content" : "insight.opinion.list", {
177
- from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
178
- rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), chiefList: maybeArray(options.chief),
179
- securityList: maybeArray(options.security), brokerList: maybeArray(options.broker), industryList: maybeArray(options.industry), conceptList: maybeArray(options.concept),
180
- llmTagList: maybeArray(options.llmTag), sourceList: maybeArray(options.source),
181
- })));
182
- addTimeFilters(summary.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("--source <number>", "Source type", collectNumberList, []).option("--research-area <id>", "Research area ID; this endpoint accepts all three code sets: citicIndustry (1008001xx), swIndustry (104xx0000), gangtiseIndustry direction (122000xxx)", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Category", collectList, []).option("--market <name>", "Market", collectList, []).option("--participant-role <name>", "Participant role", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.summary.list", {
183
- from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
184
- searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword, sourceList: options.source.length ? options.source : undefined,
185
- researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution),
186
- categoryList: maybeArray(options.category), marketList: maybeArray(options.market), participantRoleList: maybeArray(options.participantRole),
187
- }), { endpointKey: "insight.summary.list", idField: "summaryId" }));
188
- 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" });
189
- const PAMIRS_CATEGORIES = ["companyAnalysis", "industryAnalysis"];
190
- const PAMIRS_MARKETS = ["aShares", "hkStocks", "usChinaConcept", "usStocks"];
191
- 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", {
192
- from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
193
- searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword,
194
- researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security),
195
- categoryList: parseChoiceList(options.category, "--category", PAMIRS_CATEGORIES), marketList: parseChoiceList(options.market, "--market", PAMIRS_MARKETS),
196
- }), { endpointKey: "insight.pamirs-summary.list", idField: "summaryId" }));
197
- addDownloadCommand(pamirsSummary, { endpointKey: "insight.pamirs-summary.download", idOption: "--summary-id", idField: "summaryId", fallbackPrefix: "pamirs-summary", fileType: { description: "File type: 1=original(default) 2=HTML", choices: ["1", "2"] }, titleListEndpoint: "insight.pamirs-summary.list" });
198
- const addScheduleList = (command, endpointKey, fields) => {
199
- const list = command.command("list");
200
- if (fields.researchArea)
201
- list.option("--research-area <id>", "Research area ID: citicIndustry code (1008001xx) or gangtiseIndustry direction code (122000xxx: macro/strategy/fixed-income/quant/overseas). swIndustry (104xx0000) returns 0 here", collectList, []);
202
- if (fields.institution)
203
- list.option("--institution <id>", "Lead institution ID", collectList, []);
204
- if (fields.security)
205
- list.option("--security <code>", "Security code", collectList, []);
206
- if (fields.object)
207
- list.option("--object <type>", "Object type: company/industry", collectList, []);
208
- if (fields.category)
209
- list.option("--category <name>", fields.category, collectList, []);
210
- if (fields.market)
211
- list.option("--market <name>", fields.market, collectList, []);
212
- if (fields.participantRole)
213
- list.option("--participant-role <name>", "Participant role: management/expert", collectList, []);
214
- if (fields.brokerType)
215
- list.option("--broker-type <name>", "Lead broker type: cnBroker/otherBroker", collectList, []);
216
- if (fields.permission)
217
- list.option("--permission <number>", "Permission: 1=public 2=private", collectNumberList, []);
218
- if (fields.location)
219
- list.option("--location <id>", "Location ID (domesticCity constant, via 'reference constant-list')", collectList, []);
220
- list.option("--format <format>", "Output format", "table").option("--output <path>", "Output path");
221
- addTimeFilters(list).action((options) => emit(options, (client) => client.call(endpointKey, {
222
- from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
223
- researchAreaList: fields.researchArea ? maybeArray(options.researchArea) : undefined,
224
- institutionList: fields.institution ? maybeArray(options.institution) : undefined,
225
- securityList: fields.security ? maybeArray(options.security) : undefined,
226
- objectList: fields.object ? maybeArray(options.object) : undefined,
227
- categoryList: fields.category ? maybeArray(options.category) : undefined,
228
- marketList: fields.market ? maybeArray(options.market) : undefined,
229
- participantRoleList: fields.participantRole ? maybeArray(options.participantRole) : undefined,
230
- brokerTypeList: fields.brokerType ? maybeArray(options.brokerType) : undefined,
231
- permission: fields.permission && options.permission?.length ? options.permission : undefined,
232
- locationList: fields.location ? maybeArray(options.location) : undefined,
233
- })));
234
- };
235
- addScheduleList(roadshow, "insight.roadshow.list", {
236
- researchArea: true, institution: true, security: true, location: true,
237
- category: "Roadshow type: earningsCall/strategyMeeting/companyAnalysis/industryAnalysis/fundRoadshow",
238
- market: "Market: aShares/hkStocks/usChinaConcept/usStocks",
239
- participantRole: true, brokerType: true, permission: true,
240
- });
241
- addScheduleList(siteVisit, "insight.site-visit.list", {
242
- researchArea: true, institution: true, security: true, location: true, object: true,
243
- category: "Site-visit form: single/series",
244
- market: "Market: aShares/hkStocks/usChinaConcept",
245
- permission: true,
246
- });
247
- addScheduleList(strategy, "insight.strategy.list", { institution: true, location: true });
248
- addScheduleList(forum, "insight.forum.list", { researchArea: true, location: true });
249
- const SECURITY_ONLY_ROW_CAP = 1000;
250
- function flagIfImplicitCapHit(data, cap, from) {
251
- if (!data || typeof data !== "object" || Array.isArray(data))
252
- return;
253
- const rec = data;
254
- const rows = rowCount(data);
255
- if (rows < cap)
256
- return;
257
- const total = typeof rec.total === "number" ? rec.total : undefined;
258
- if (total !== undefined && from + rows >= total)
259
- return;
260
- rec.partial = true;
261
- process.stderr.write(`[gangtise] warning: --security was the only bound, so the fetch was capped at ${cap} rows and more remain (total=${String(rec.total)}) — the filter may not have narrowed anything. Re-run with --start-date/--end-date or an explicit --size.\n`);
262
- }
263
- const PERFORMANCE_MARKETS = ["aShares", "hkStocks", "usChinaConcept", "usStocks"];
264
- const PERFORMANCE_CATEGORIES = ["performanceForecast", "performanceExpress", "performanceAnnouncement"];
265
- performanceCalendar.command("list").description("Earnings calendar (业绩预告 / 快报 / 公告)")
266
- .option("--from <number>", "Starting offset", "0")
267
- .option("--size <number>", "Total rows to return; omit to fetch all")
268
- .option("--start-date <date>", "Start date (yyyy-MM-dd), filters publishDate", dateArg("--start-date"))
269
- .option("--end-date <date>", "End date (yyyy-MM-dd), filters publishDate", dateArg("--end-date"))
270
- .option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, [])
271
- .option("--market <name>", `Market: ${PERFORMANCE_MARKETS.join("/")}`, collectList, [])
272
- .option("--category <name>", `Event type: ${PERFORMANCE_CATEGORIES.join("/")}`, collectList, [])
273
- .option("--format <format>", "Output format", "table").option("--output <path>", "Output path")
274
- .action((options) => {
275
- const marketList = parseChoiceList(options.market, "--market", PERFORMANCE_MARKETS);
276
- const categoryList = parseChoiceList(options.category, "--category", PERFORMANCE_CATEGORIES);
277
- const explicitlyBounded = Boolean(options.size) || Boolean(options.startDate && options.endDate);
278
- if (!explicitlyBounded && !options.security.length) {
279
- 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");
280
- }
281
- const implicitCap = explicitlyBounded ? undefined : SECURITY_ONLY_ROW_CAP;
282
- const from = parseFrom(options.from);
283
- return emit(options, async (client) => {
284
- const data = await client.call("insight.performance-calendar.list", {
285
- from, size: parseSize(options.size) ?? implicitCap,
286
- startDate: options.startDate, endDate: options.endDate,
287
- marketList,
288
- securityList: maybeArray(options.security),
289
- categoryList,
290
- });
291
- if (implicitCap)
292
- flagIfImplicitCapHit(data, implicitCap, from);
293
- return data;
294
- }, { endpointKey: "insight.performance-calendar.list", idField: "performanceReportId" });
295
- });
296
- addDownloadCommand(performanceCalendar, { endpointKey: "insight.performance-calendar.download", idOption: "--performance-report-id", idField: "performanceReportId", fallbackPrefix: "performance-calendar", titleListEndpoint: "insight.performance-calendar.list" });
297
- addTimeFilters(research.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("--broker <id>", "Broker ID", collectList, []).option("--security <code>", "Security code", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--category <name>", "Report category", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--min-pages <number>", "Min report pages").option("--max-pages <number>", "Max report pages").option("--source <type>", "Source type", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.research.list", {
298
- from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
299
- searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
300
- brokerList: maybeArray(options.broker), securityList: maybeArray(options.security), industryList: maybeArray(options.industry),
301
- categoryList: maybeArray(options.category), llmTagList: maybeArray(options.llmTag), ratingList: maybeArray(options.rating),
302
- ratingChangeList: maybeArray(options.ratingChange), minReportPages: parseOptionalNumberOption(options.minPages, "--min-pages", { integer: true, min: 0 }),
303
- maxReportPages: parseOptionalNumberOption(options.maxPages, "--max-pages", { integer: true, min: 0 }), sourceList: maybeArray(options.source),
304
- }), { endpointKey: "insight.research.list", idField: "reportId" }));
305
- addDownloadCommand(research, { endpointKey: "insight.research.download", idOption: "--report-id", idField: "reportId", fallbackPrefix: "research", fileType: { description: "File type: 1=PDF 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.research.list" });
306
- addTimeFilters(foreignReport.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("--region <id>", "Region ID", collectList, []).option("--category <name>", "Report category", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--llm-tag <tag>", "Semantic tag", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--min-pages <number>", "Min report pages").option("--max-pages <number>", "Max report pages").option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-report.list", {
307
- from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword,
308
- searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
309
- securityList: maybeArray(options.security), regionList: maybeArray(options.region), categoryList: maybeArray(options.category),
310
- industryList: maybeArray(options.industry), brokerList: maybeArray(options.broker), llmTagList: maybeArray(options.llmTag),
311
- ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
312
- minReportPages: parseOptionalNumberOption(options.minPages, "--min-pages", { integer: true, min: 0 }), maxReportPages: parseOptionalNumberOption(options.maxPages, "--max-pages", { integer: true, min: 0 }),
313
- }), { endpointKey: "insight.foreign-report.list", idField: "reportId" }));
314
- 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" });
315
- 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", {
316
- from: parseFrom(options.from), size: parseSize(options.size),
317
- startTime: parseTimestamp13(options.startTime, "--start-time"), endTime: parseTimestamp13(options.endTime, "--end-time"),
318
- searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }), rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }), keyword: options.keyword,
319
- securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
320
- }), { endpointKey: "insight.announcement.list", idField: "announcementId" }));
321
- addDownloadCommand(announcement, { endpointKey: "insight.announcement.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement", fileType: { description: "File type: 1=PDF 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.announcement.list" });
322
- addTimeFilters(announcementHk.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 (e.g. 01913.HK)", 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-hk.list", {
323
- from: parseFrom(options.from), size: parseSize(options.size),
324
- startTime: options.startTime, endTime: options.endTime,
325
- searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }),
326
- rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
327
- keyword: options.keyword,
328
- securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
329
- }), { endpointKey: "insight.announcement-hk.list", idField: "announcementId" }));
330
- addDownloadCommand(announcementHk, { endpointKey: "insight.announcement-hk.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-hk", fileType: { description: "File type: 1=original 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.announcement-hk.list" });
331
- addTimeFilters(announcementUs.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 (e.g. TSLA.O)", collectList, []).option("--category <id>", "Category ID (constant-list usShareAnnouncementCategory)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.announcement-us.list", {
332
- from: parseFrom(options.from), size: parseSize(options.size),
333
- startTime: options.startTime, endTime: options.endTime,
334
- searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }),
335
- rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
336
- keyword: options.keyword,
337
- securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
338
- }), { endpointKey: "insight.announcement-us.list", idField: "announcementId" }));
339
- 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" });
340
- 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 -- this endpoint accepts only cn/cnHk/cnTw/us/jp/uk; the other 13 values of regionCategory (sea/gl/fr/de/kr/in/ca/me/othAs/othEur/latAm/oce/af) are rejected here with 100005 though they all work on foreign-report", collectList, []).option("--industry <id>", "Industry ID -- swIndustry codes only (104xx0000); citicIndustry codes are rejected with 100005 even where constant-category declares them", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--with-content", "Return the full body inline (v1 list, 30 credits/row) as content / contentTranslate, instead of the 200-char brief (1 credit/row)").option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call(options.withContent ? "insight.foreign-opinion.list-with-content" : "insight.foreign-opinion.list", {
341
- from: parseFrom(options.from), size: parseSize(options.size),
342
- startTime: options.startTime, endTime: options.endTime,
343
- rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
344
- keyword: options.keyword,
345
- regionList: maybeArray(options.region), industryList: maybeArray(options.industry),
346
- securityList: maybeArray(options.security), brokerList: maybeArray(options.broker),
347
- ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
348
- })));
349
- async function fetchOpinionDetails(client, endpointKey, idListField, idField, ids) {
350
- const BATCH = 20;
351
- const unique = [...new Set(ids)];
352
- if (!unique.length)
353
- throw new ValidationError("pass at least one opinion ID");
354
- const list = [];
355
- let unfetched = [];
356
- let unfetchedError;
357
- for (let i = 0; i < unique.length; i += BATCH) {
358
- try {
359
- const rows = await client.call(endpointKey, { [idListField]: unique.slice(i, i + BATCH) });
360
- if (Array.isArray(rows))
361
- list.push(...rows);
362
- }
363
- catch (error) {
364
- if (i === 0)
365
- throw error;
366
- unfetched = unique.slice(i);
367
- const message = error instanceof Error ? error.message : String(error);
368
- unfetchedError = { message, ...(error instanceof ApiError ? { code: error.code, traceId: error.traceId } : {}) };
369
- process.stderr.write(`[gangtise] warning: ${endpointKey} failed on batch ${i / BATCH + 1} (${message}); ${unfetched.length} ID(s) not fetched: ${unfetched.join(", ")}. The bodies already returned are kept below — re-run for the IDs in unfetchedIds only.\n`);
370
- break;
371
- }
372
- }
373
- const returned = new Set(list.map((row) => String(row[idField])));
374
- const missing = unique.filter((id) => !returned.has(id) && !unfetched.includes(id));
375
- const out = { total: list.length, list };
376
- if (missing.length > 0) {
377
- process.stderr.write(`[gangtise] warning: no body returned for ${missing.length} ID(s): ${missing.join(", ")} — the server skips an ID it has no body for without an error (e.g. a wrong ID; a just-published opinion may also not have its body yet). Result marked partial (exit 3).\n`);
378
- out.missingIds = missing;
379
- }
380
- if (unfetched.length > 0) {
381
- out.unfetchedIds = unfetched;
382
- out.unfetchedError = unfetchedError;
383
- }
384
- if (missing.length > 0 || unfetched.length > 0)
385
- out.partial = true;
386
- return out;
387
- }
388
- opinion.command("detail").description("Full bodies of domestic chief opinions by ID (30 credits per returned opinion; batched 20 per call)")
389
- .requiredOption("--chief-opinion-id <id>", "chiefOpinionId from 'insight opinion list' (repeat or comma-separate)", collectList)
390
- .option("--format <format>", "Output format", "json").option("--output <path>")
391
- .action((options) => emit(options, (client) => fetchOpinionDetails(client, "insight.opinion.detail", "chiefOpinionIdList", "chiefOpinionId", options.chiefOpinionId)));
392
- foreignOpinion.command("detail").description("Full bodies (content + contentTranslate) of foreign opinions by ID (30 credits per returned opinion; batched 20 per call)")
393
- .requiredOption("--foreign-opinion-id <id>", "foreignOpinionId from 'insight foreign-opinion list' (repeat or comma-separate)", collectList)
394
- .option("--format <format>", "Output format", "json").option("--output <path>")
395
- .action((options) => emit(options, (client) => fetchOpinionDetails(client, "insight.foreign-opinion.detail", "foreignOpinionIdList", "foreignOpinionId", options.foreignOpinionId)));
396
- addTimeFilters(independentOpinion.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. GSK.N)", collectList, []).option("--industry <id>", "Industry ID -- swIndustry codes only (104xx0000); citicIndustry codes are rejected with 100005 even where constant-category declares them", 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.independent-opinion.list", {
397
- from: parseFrom(options.from), size: parseSize(options.size),
398
- startTime: options.startTime, endTime: options.endTime,
399
- rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
400
- keyword: options.keyword,
401
- industryList: maybeArray(options.industry), securityList: maybeArray(options.security),
402
- ratingList: maybeArray(options.rating), ratingChangeList: maybeArray(options.ratingChange),
403
- })));
404
- addDownloadCommand(independentOpinion, { endpointKey: "insight.independent-opinion.download", idOption: "--independent-opinion-id", idField: "independentOpinionId", fallbackPrefix: "independent-opinion", fileType: { description: "File type: 1=original HTML 2=CN-translated HTML", choices: ["1", "2"], required: true } });
405
- addTimeFilters(officialAccount.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("--account-id <id>", "Official account ID", collectList, []).option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, []).option("--category <type>", "Article type: news/law/report/view/data/event/meeting/notice/recruit/investEdu/brand/notes/other", collectList, []).option("--industry <id>", "Industry ID (constant-list citicIndustry/swIndustry)", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.official-account.list", {
406
- from: parseFrom(options.from), size: parseSize(options.size),
407
- startTime: options.startTime, endTime: options.endTime,
408
- searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }),
409
- rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
410
- keyword: options.keyword,
411
- accountIdList: maybeArray(options.accountId), securityList: maybeArray(options.security),
412
- categoryList: maybeArray(options.category), industryList: maybeArray(options.industry),
413
- }), { endpointKey: "insight.official-account.list", idField: "articleId" }));
414
- 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" });
415
- 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", {
416
- from: parseFrom(options.from), size: parseSize(options.size),
417
- securityCode: options.securityCode, startTime: options.startTime, endTime: options.endTime,
418
- source: maybeArray(options.source), questionCategory: maybeArray(options.questionCategory),
419
- answerImportant: options.answerImportant.length ? options.answerImportant : undefined,
420
- })));
421
- reportImage.command("list").requiredOption("--keyword <text>", "Search keyword, e.g. 'AI' '新能源汽车'").option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--source-id <id>", "Report source ID, to filter to one report (from a report list or knowledge base)").option("--start-time <datetime>", "Start time (yyyy-MM-dd HH:mm:ss; yyyy-MM-dd auto-completed)", datetimeArg("--start-time")).option("--end-time <datetime>", "End time (yyyy-MM-dd HH:mm:ss; yyyy-MM-dd auto-completed)", datetimeArg("--end-time")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("insight.report-image.list", {
422
- keyword: options.keyword, top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 20 }),
423
- sourceId: options.sourceId, startTime: options.startTime, endTime: options.endTime,
424
- })));
425
- addDownloadCommand(reportImage, { endpointKey: "insight.report-image.download", idOption: "--chunk-id", idField: "chunkId", fallbackPrefix: "report-image" });
426
- insight.addCommand(opinion);
427
- insight.addCommand(summary);
428
- insight.addCommand(pamirsSummary);
429
- insight.addCommand(roadshow);
430
- insight.addCommand(siteVisit);
431
- insight.addCommand(strategy);
432
- insight.addCommand(forum);
433
- insight.addCommand(performanceCalendar);
434
- insight.addCommand(research);
435
- insight.addCommand(foreignReport);
436
- insight.addCommand(announcement);
437
- insight.addCommand(announcementHk);
438
- insight.addCommand(announcementUs);
439
- insight.addCommand(foreignOpinion);
440
- insight.addCommand(independentOpinion);
441
- insight.addCommand(officialAccount);
442
- insight.addCommand(qa);
443
- insight.addCommand(reportImage);
444
- const highlight = new Command("highlight");
445
- highlight.command("list").description("Meeting highlights feed: key takeaways per meeting, newest first (5 credits/row)")
446
- .option("--from <number>", "Starting offset", "0")
447
- .option("--size <number>", "Total rows to return; omit to fetch all (max page 50). Billed per row — bound this on wide ranges")
448
- .option("--start-time <datetime>", "Start time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)", datetimeArg("--start-time"))
449
- .option("--end-time <datetime>", "End time (yyyy-MM-dd or yyyy-MM-dd HH:mm:ss)", datetimeArg("--end-time"))
450
- .option("--security <code>", "Security code, e.g. 000001.SZ / 09992.HK / AAPL.O (repeatable)", collectList, [])
451
- .option("--research-area <id>", "Research area ID: citicIndustry (1008001xx) or gangtiseIndustry direction (122000xxx). swIndustry codes are NOT accepted here", collectList, [])
452
- .option("--format <format>", "Output format", "table").option("--output <path>")
453
- .action((options) => emit(options, (client) => client.call("insight.highlight.list", {
454
- from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime,
455
- securityList: maybeArray(options.security), researchAreaList: maybeArray(options.researchArea),
456
- })));
457
- insight.addCommand(highlight);
458
- program.addCommand(insight);
459
- const quote = new Command("quote").description("Quote APIs");
460
- const KLINE_MARKETS = { aShares: 1, hkStocks: 2, usStocks: 1 };
461
- const LEGACY_ALL_MARKET = (shardDays) => ({ all: shardDays });
462
- const REALTIME_MARKETS = ["aShares", "hkStocks", "usStocks"];
463
- const FUND_FLOW_MARKETS = ["aShares"];
464
- const MARKET_KEYWORDS = new Set(["all", "ashares", "hkstocks", "usstocks"]);
465
- const matchesMarketKeyword = (value, keyword) => value.toLowerCase() === keyword.toLowerCase();
466
- const checkMarketKeywords = (securities, accepted, command, noKeywordReason) => {
467
- const used = securities.filter((s) => MARKET_KEYWORDS.has(s.toLowerCase()));
468
- if (used.length === 0)
469
- return;
470
- const unsupported = used.filter((k) => !accepted.some((a) => matchesMarketKeyword(k, a)));
471
- if (unsupported.length > 0) {
472
- throw new ValidationError(accepted.length > 0
473
- ? `${command}: '${unsupported[0]}' is not a whole-market keyword for this command — use ${accepted.join(" / ")}`
474
- : `${command}: this command takes explicit security codes only — '${unsupported[0]}' and other whole-market keywords are not supported${noKeywordReason ? `: ${noKeywordReason}` : ""}`);
475
- }
476
- if (securities.length > 1) {
477
- 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`);
478
- }
479
- };
480
- const canonicalizeMarketKeywords = (securities, accepted) => securities.map((s) => accepted.find((a) => matchesMarketKeyword(s, a)) ?? s);
481
- const addKlineCommand = (name, endpointKey, securityHelp, markets, noKeywordReason) => quote.command(name)
482
- .option("--security <code>", securityHelp, collectList, [])
483
- .option("--start-date <date>", "Start date (default: 1 year before end-date)", dateArg("--start-date"))
484
- .option("--end-date <date>", "End date (default: latest)", dateArg("--end-date"))
485
- .option("--limit <number>", "Max rows per request (default: 6000, max: 10000)")
486
- .option("--field <field>", "Field", collectList, [])
487
- .option("--format <format>", "Output format", "table")
488
- .option("--output <path>")
489
- .action((options) => {
490
- checkMarketKeywords(options.security, Object.keys(markets), `quote ${name}`, noKeywordReason);
491
- options.security = canonicalizeMarketKeywords(options.security, Object.keys(markets));
492
- return withClient(options, async (client) => {
493
- const format = parseOutputFormat(options.format);
494
- const body = buildQuoteKlineBody(options);
495
- const keyword = Object.keys(markets).find((k) => isFullMarket(body, k));
496
- if (keyword) {
497
- const data = await callKlineWithSharding(client, endpointKey, body, { shardDays: markets[keyword], fullMarketValue: keyword });
498
- flagMissingFields(data, body.fieldList, `quote ${name}`);
499
- await printData(data, format, options.output);
500
- return;
501
- }
502
- const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
503
- const securities = body.securityList ?? [];
504
- if (securities.length > 1 && securities.length * estimateTradingDays(body.startDate, body.endDate) > limit) {
505
- const data = await callPerSecurity(client, endpointKey, securities, (code) => ({ ...body, securityList: [code], limit }), limit, `quote ${name}`);
506
- flagMissingFields(data, body.fieldList, `quote ${name}`);
507
- await printData(data, format, options.output);
508
- return;
509
- }
510
- const data = await client.call(endpointKey, { ...body, limit });
511
- flagIfLimitTruncated(data, limit, name);
512
- flagMissingFields(data, body.fieldList, `quote ${name}`);
513
- await printData(data, format, options.output);
514
- });
515
- });
516
- addKlineCommand("day-kline", "quote.day-kline", "Security code — A-share .SH/.SZ/.BJ, ETF .SH/.SZ (e.g. 512800.SH), HK .HK, US .O/.N/.A, exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI, global index (e.g. SPX.SPI / N225.NKI / HSI.HI); or one market keyword: aShares / hkStocks / usStocks (auto-sharded by date, must be passed alone; keywords cover stocks only — ETFs and indices must be listed by code)", KLINE_MARKETS);
517
- 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));
518
- 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));
519
- addKlineCommand("index-day-kline", "quote.index-day-kline", "[deprecated: use 'day-kline'] Index code (.SH/.SZ/.BJ), repeat for several; no whole-market keyword — 'all' is refused because the endpoint answers it with an empty result", {}, "the endpoint answers 'all' with an empty result. List the index codes one by one — quote day-kline takes the same codes");
520
- quote.command("minute-kline").option("--security <code>", "Security code — A-share .SH/.SZ (SH/SZ only), ETF .SH/.SZ (e.g. 512800.SH), exchange index .SH/.SZ, concept index .GT, industry index .CI/.SWI, global index (e.g. SPX.SPI / N225.NKI / HSI.HI); repeat for several — one request each, run concurrently and merged; no whole-market keyword", collectList, []).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(options, async (client) => {
521
- const format = parseOutputFormat(options.format);
522
- const limit = parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 10000 }) ?? DEFAULT_QUOTE_LIMIT;
523
- const fieldList = maybeArray(options.field);
524
- const securities = options.security;
525
- if (securities.length === 0)
526
- throw new ValidationError("--security is required (repeat it for several securities)");
527
- const makeBody = (code) => ({ securityCode: code, startTime: options.startTime, endTime: options.endTime, limit, fieldList });
528
- const data = securities.length === 1
529
- ? await client.call("quote.minute-kline", makeBody(securities[0]))
530
- : await callPerSecurity(client, "quote.minute-kline", securities, makeBody, limit, "quote minute-kline");
531
- if (securities.length === 1)
532
- flagIfLimitTruncated(data, limit, "minute-kline", "--start-time/--end-time");
533
- flagMissingFields(data, fieldList, "quote minute-kline");
534
- await printData(data, format, options.output);
535
- }));
536
- quote.command("realtime").description("Realtime quote snapshot (A-share / HK / US stocks, ETFs, and indices incl. 20 global indices)").option("--security <code>", "Security code — stock .SH/.SZ/.BJ/.HK/.O/.N/.A, ETF .SH/.SZ (e.g. 512800.SH), exchange index .SH/.SZ/.BJ, concept index .GT, industry index .CI/.SWI, global index (e.g. SPX.SPI / N225.NKI / HSI.HI); or one market keyword: aShares / hkStocks / usStocks (must be passed alone; keywords cover stocks only — ETFs and indices have no whole-market keyword)", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
537
- checkMarketKeywords(options.security, REALTIME_MARKETS, "quote realtime");
538
- return emit(options, async (client) => {
539
- const fieldList = maybeArray(options.field);
540
- const data = await client.call("quote.realtime", { securityList: maybeArray(options.security), fieldList });
541
- flagMissingFields(data, fieldList, "quote realtime");
542
- return data;
543
- });
544
- });
545
- quote.command("fund-flow").description("A-share daily fund flow (SH/SZ/BJ)").option("--security <code>", "Security code (e.g. 600519.SH / 920982.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) => {
546
- checkMarketKeywords(options.security, FUND_FLOW_MARKETS, "quote fund-flow");
547
- options.security = canonicalizeMarketKeywords(options.security, FUND_FLOW_MARKETS);
548
- return withClient(options, async (client) => {
549
- const format = parseOutputFormat(options.format);
550
- const body = {
551
- securityList: maybeArray(options.security),
552
- startDate: options.startDate,
553
- endDate: options.endDate,
554
- limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 10000 }),
555
- fieldList: maybeArray(options.field),
556
- };
557
- if (isFullMarket(body, "aShares")) {
558
- if (!body.startDate || !body.endDate) {
559
- throw new ValidationError("quote fund-flow --security aShares requires both --start-date and --end-date (the full market is fetched via per-day shards)");
560
- }
561
- const data = await callKlineWithSharding(client, "quote.fund-flow", body, { shardDays: 1, fullMarketValue: "aShares" });
562
- flagMissingFields(data, body.fieldList, "quote fund-flow");
563
- await printData(data, format, options.output);
564
- return;
565
- }
566
- const limit = body.limit ?? DEFAULT_QUOTE_LIMIT;
567
- const data = await client.call("quote.fund-flow", { ...body, limit });
568
- flagIfLimitTruncated(data, limit, "fund-flow");
569
- flagMissingFields(data, body.fieldList, "quote fund-flow");
570
- await printData(data, format, options.output);
571
- });
572
- });
573
- program.addCommand(quote);
574
- const fundamental = new Command("fundamental").description("Fundamental APIs");
575
- const addFinancialReport = (name, endpointKey, periodHelp = "Period") => fundamental.command(name).requiredOption("--security-code <code>").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>", periodHelp, collectList, []).option("--report-type <type>", "Report type", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call(endpointKey, { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, fiscalYear: maybeArray(options.fiscalYear), period: options.period.length ? options.period : undefined, reportType: options.reportType.length ? options.reportType : undefined, fieldList: maybeArray(options.field) })));
576
- addFinancialReport("income-statement", "fundamental.income-statement");
577
- addFinancialReport("income-statement-quarterly", "fundamental.income-statement-quarterly", "Period: q1/q2/q3/q4/latest");
578
- addFinancialReport("balance-sheet", "fundamental.balance-sheet");
579
- addFinancialReport("cash-flow", "fundamental.cash-flow");
580
- addFinancialReport("cash-flow-quarterly", "fundamental.cash-flow-quarterly", "Period: q1/q2/q3/q4/latest");
581
- addFinancialReport("income-statement-hk", "fundamental.income-statement-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
582
- addFinancialReport("balance-sheet-hk", "fundamental.balance-sheet-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
583
- addFinancialReport("cash-flow-hk", "fundamental.cash-flow-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
584
- addFinancialReport("income-statement-us", "fundamental.income-statement-us", "Period: q1/h1/q3/nsd/annual/latest");
585
- addFinancialReport("balance-sheet-us", "fundamental.balance-sheet-us", "Period: q1/h1/q3/nsd/annual/latest");
586
- addFinancialReport("cash-flow-us", "fundamental.cash-flow-us", "Period: q1/h1/q3/nsd/annual/latest");
587
- fundamental.command("main-business").requiredOption("--security-code <code>").option("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).addOption(new Option("--breakdown <type>", "Breakdown: product/industry/region").choices(["product", "industry", "region"]).default("product")).option("--period <type>", "Period: interim/annual", collectList, []).option("--field <field>", "Field", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("fundamental.main-business", { securityCode: options.securityCode, startDate: options.startDate, endDate: options.endDate, breakdown: options.breakdown, periodList: maybeArray(options.period), fieldList: maybeArray(options.field) })));
588
- const VALUATION_DEFAULT_LIMIT = 2000;
589
- function valuationFieldsToSend(fields) {
590
- const unique = [...new Set(fields)];
591
- const values = unique.filter((field) => field !== "tradeDate");
592
- return values.length > 0 ? values : unique;
593
- }
594
- function firstRowTradeDate(data) {
595
- const normalized = normalizeRows(data);
596
- const list = Array.isArray(normalized) ? normalized : normalized?.list;
597
- const first = Array.isArray(list) ? list[0] : undefined;
598
- const value = first && typeof first === "object" ? first.tradeDate : undefined;
599
- return typeof value === "string" ? value : undefined;
30
+ for (const group of [auth, lookup, insight, quote, fundamental, bond, reference, vault, ai, alternative, indicator, tool, raw]) {
31
+ program.addCommand(group);
600
32
  }
601
- fundamental.command("valuation-analysis").requiredOption("--security-code <code>").addOption(new Option("--indicator <name>", "Indicator").choices(["peTtm", "pbMrq", "peg", "psTtm", "pcfTtm", "em"]).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("--limit <number>", "Max rows (default: 2000). One row per calendar day, weekends included, and the most recent are kept — a range longer than the limit loses its START (flagged partial, exit 3)").option("--field <field>", "Field", collectList, []).option("--skip-null", "Drop rows where value or percentileRank is null").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
602
- const format = parseOutputFormat(options.format);
603
- const requested = maybeArray(options.field);
604
- const filterFields = options.skipNull && requested ? ["value", "percentileRank"].filter((field) => !requested.includes(field)) : [];
605
- const fieldList = requested && valuationFieldsToSend([...requested, ...filterFields]);
606
- const limit = parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }) ?? VALUATION_DEFAULT_LIMIT;
607
- let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit, fieldList });
608
- const firstDate = firstRowTradeDate(data);
609
- if (!(options.startDate && firstDate === options.startDate)) {
610
- flagIfLimitTruncated(data, limit, "fundamental valuation-analysis", "--start-date", `The API keeps the most recent rows, so it is the START of the range that is missing. The series has one row per calendar day (weekends included): raise --limit to at least the number of days in the range (e.g. --limit 4000 for ten years within your account's history window), or move --start-date later.`);
611
- }
612
- if (options.startDate && firstDate && firstDate > options.startDate && !data.partial) {
613
- process.stderr.write(`[gangtise] note: the series starts at ${firstDate}, later than --start-date ${options.startDate}. Either the security listed later, or the range reaches past your account's history window — this endpoint returns what lies inside the window without an error.\n`);
614
- }
615
- if (options.skipNull) {
616
- const normalized = normalizeRows(data);
617
- if (normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
618
- const rec = normalized;
619
- if (Array.isArray(rec.list)) {
620
- const filtered = rec.list.filter((row) => {
621
- if (!row || typeof row !== "object")
622
- return false;
623
- const r = row;
624
- return r.value != null && r.percentileRank != null;
625
- }).map((row) => {
626
- if (filterFields.length === 0)
627
- return row;
628
- const r = { ...row };
629
- for (const field of filterFields)
630
- delete r[field];
631
- return r;
632
- });
633
- data = { ...rec, list: filtered, total: filtered.length };
634
- }
635
- }
636
- }
637
- await printData(data, format, options.output);
638
- }));
639
- 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 })));
640
- 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) => {
641
- const endDate = options.endDate ?? localDateString(new Date());
642
- const startDate = options.startDate ?? new Date(new Date(`${endDate}T00:00:00Z`).getTime() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
643
- return client.call("fundamental.earning-forecast", { securityCode: options.securityCode, startDate, endDate, consensusList: maybeArray(options.consensus) });
644
- }));
645
- program.addCommand(fundamental);
646
- const bond = new Command("bond").description("Bond APIs: profiles, issuers, quotes, valuations, cash flows, ratings, announcements");
647
- function requireBondCodes(codes, max) {
648
- if (!codes.length)
649
- throw new ValidationError("--security is required: pass one or more bond codes, e.g. --security 019742.SH --security 220205.IB");
650
- const unique = [...new Set(codes)];
651
- if (max && unique.length > max)
652
- throw new ValidationError(`${unique.length} distinct bond codes in one call — this endpoint takes at most ${max}. Split them into batches of ${max} and run one call per batch.`);
653
- return unique;
654
- }
655
- const bondSecurityOption = (command) => command
656
- .option("--security <code>", "Bond code, e.g. 019742.SH / 123456.SZ / 220205.IB (repeatable). Short names and pinyin are rejected — resolve them with 'reference securities-search' first", collectList, [])
657
- .option("--field <field>", "Field to return (repeatable); omit for all. An unsupported name rejects the whole call with 100003", collectList, [])
658
- .option("--format <format>", "Output format", "table").option("--output <path>");
659
- const addBondRange = (name, endpointKey, describe, rangeHelp, max) => bondSecurityOption(bond.command(name).description(describe))
660
- .option("--start-date <date>", `${rangeHelp} start (yyyy-MM-dd); omit for all history`, dateArg("--start-date"))
661
- .option("--end-date <date>", `${rangeHelp} end (yyyy-MM-dd); omit for all history`, dateArg("--end-date"))
662
- .action((options) => emit(options, (client) => client.call(endpointKey, { securityList: requireBondCodes(options.security, max), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
663
- bondSecurityOption(bond.command("basic-info").description("Bond static profiles: issuance, term, coupon, rating, guarantee, special terms"))
664
- .action((options) => emit(options, (client) => client.call("bond.basic-info", { securityList: requireBondCodes(options.security, 10000), fieldList: maybeArray(options.field) })));
665
- bondSecurityOption(bond.command("rating-overview").description("Bond, issuer and guarantor ratings side by side"))
666
- .action((options) => emit(options, (client) => client.call("bond.rating-overview", { securityList: requireBondCodes(options.security, 10), fieldList: maybeArray(options.field) })));
667
- addBondRange("cash-flow", "bond.cash-flow", "Interest payment and redemption schedule per bond", "Payment date");
668
- addBondRange("issuance-detail", "bond.issuance-detail", "Issuance and re-issuance records: bidding, pricing, cover ratios", "Issue announcement date");
669
- addBondRange("rating-change", "bond.rating-change", "Bond rating change history: current vs previous rating, direction, outlook, agency (max 10 bonds per call)", "Announcement date", 10);
670
- addBondRange("exercise-notice", "bond.exercise-notice", "Put/call exercise schedule and results for option-embedded bonds", "Exercise date");
671
- bondSecurityOption(bond.command("daily-quote").description("Daily close quotes (exchange + CFETS): dirty/clean price, YTM, duration, convexity"))
672
- .requiredOption("--start-date <date>", "Trade date range start (yyyy-MM-dd)", dateArg("--start-date"))
673
- .requiredOption("--end-date <date>", "Trade date range end (yyyy-MM-dd)", dateArg("--end-date"))
674
- .action((options) => emit(options, (client) => client.call("bond.daily-quote", { securityList: requireBondCodes(options.security), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
675
- bondSecurityOption(bond.command("valuation").description("Shanghai Clearing House valuations: price, yield, duration, convexity, PVBP"))
676
- .requiredOption("--start-date <date>", "Valuation date range start (yyyy-MM-dd)", dateArg("--start-date"))
677
- .requiredOption("--end-date <date>", "Valuation date range end (yyyy-MM-dd)", dateArg("--end-date"))
678
- .addOption(new Option("--confidence-level <level>", "Valuation confidence; defaults to 推荐 server-side, which already filters out 不推荐").choices(["推荐", "不推荐"]))
679
- .action((options) => emit(options, (client) => client.call("bond.valuation", { securityList: requireBondCodes(options.security), startDate: options.startDate, endDate: options.endDate, confidenceLevel: options.confidenceLevel, fieldList: maybeArray(options.field) })));
680
- function issuerSelector(security, issuer) {
681
- if (security.length && issuer.length)
682
- throw new ValidationError("--security and --issuer are mutually exclusive: pass bond codes or issuer names, not both");
683
- if (!security.length && !issuer.length)
684
- throw new ValidationError("pass either --security (bond codes) or --issuer (issuer names)");
685
- return security.length ? { securityList: security } : { issuerNameList: issuer };
686
- }
687
- const issuerNameOption = (command) => command.option("--issuer <name>", "Issuer name, full or short (repeatable); fuzzy-matched, one best hit per name", collectList, []);
688
- issuerNameOption(bondSecurityOption(bond.command("issuer-info").description("Issuer profiles: nature, SW industry, registration, rating, outstanding bonds")))
689
- .action((options) => emit(options, (client) => client.call("bond.issuer-info", { ...issuerSelector(options.security, options.issuer), fieldList: maybeArray(options.field) })));
690
- issuerNameOption(bondSecurityOption(bond.command("issuer-rating-change").description("Issuer rating change history (at most 10 issuers matched per call)")))
691
- .option("--start-date <date>", "Announcement date range start (yyyy-MM-dd); omit for all history", dateArg("--start-date"))
692
- .option("--end-date <date>", "Announcement date range end (yyyy-MM-dd); omit for all history", dateArg("--end-date"))
693
- .action((options) => emit(options, (client) => client.call("bond.issuer-rating-change", { ...issuerSelector(options.security, options.issuer), startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
694
- bond.command("issuance-plan").description("Rate-bond issuance calendar over a date range")
695
- .requiredOption("--start-date <date>", "Issue date range start (yyyy-MM-dd)", dateArg("--start-date"))
696
- .requiredOption("--end-date <date>", "Issue date range end (yyyy-MM-dd)", dateArg("--end-date"))
697
- .option("--field <field>", "Field to return (repeatable); omit for all", collectList, [])
698
- .option("--format <format>", "Output format", "table").option("--output <path>")
699
- .action((options) => emit(options, (client) => client.call("bond.issuance-plan", { startDate: options.startDate, endDate: options.endDate, fieldList: maybeArray(options.field) })));
700
- bondSecurityOption(bond.command("announcement").description("Bond announcements, paged. Filter by bond codes OR by announcement date range, never both"))
701
- .option("--start-date <date>", "Announcement date range start (yyyy-MM-dd); mutually exclusive with --security", dateArg("--start-date"))
702
- .option("--end-date <date>", "Announcement date range end (yyyy-MM-dd); mutually exclusive with --security", dateArg("--end-date"))
703
- .option("--page-no <number>", "Page number, from 1. No total is returned: increment until a page comes back empty", "1")
704
- .option("--page-size <number>", "Rows per page, 1-200", "50")
705
- .action((options) => emit(options, (client) => {
706
- const byDate = Boolean(options.startDate || options.endDate);
707
- if (options.security.length && byDate)
708
- throw new ValidationError("--security and --start-date/--end-date are mutually exclusive on bond announcement: filter by bond codes or by date range, not both");
709
- if (!options.security.length && !byDate)
710
- throw new ValidationError("pass either --security (bond codes) or --start-date/--end-date (announcement date range)");
711
- return client.call("bond.announcement", {
712
- securityList: maybeArray(options.security), startDate: options.startDate, endDate: options.endDate,
713
- pageNo: parseNumberOption(options.pageNo, "--page-no", { integer: true, min: 1 }),
714
- pageSize: parseNumberOption(options.pageSize, "--page-size", { integer: true, min: 1, max: 200 }),
715
- fieldList: maybeArray(options.field),
716
- });
717
- }));
718
- program.addCommand(bond);
719
- const ai = new Command("ai").description("AI APIs");
720
- ai.command("knowledge-batch").option("--query <text>", "Query text; repeat for up to 5. Each --query is taken whole — commas inside it are part of the question, not separators", collectText, []).option("--top <number>", "Max results (default: 10, max: 20)", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <datetime>", "13/10-digit epoch or YYYY-MM-DD[ HH:mm[:ss]] (space or T)").option("--end-time <datetime>", "13/10-digit epoch or YYYY-MM-DD[ HH:mm[:ss]] (space or T)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
721
- if (!options.query.length)
722
- throw new ValidationError("--query is required: pass at least one --query");
723
- return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 20 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseTimestamp13(options.startTime, "--start-time"), endTime: parseTimestamp13(options.endTime, "--end-time") }));
724
- });
725
- ai.command("knowledge-resource-download").requiredOption("--resource-type <number>").requiredOption("--source-id <id>").option("--output <path>").action((options) => withClient(async (client) => {
726
- await runDownload(client, "ai.knowledge-resource.download", { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }, {
727
- output: options.output,
728
- fallbackName: `resource-${options.sourceId}`,
729
- });
730
- }));
731
- ai.command("security-clue").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").requiredOption("--start-time <datetime>", "Start time", datetimeArg("--start-time")).requiredOption("--end-time <datetime>", "End time", datetimeArg("--end-time")).addOption(new Option("--query-mode <mode>").choices(["bySecurity", "byIndustry"]).makeOptionMandatory()).option("--gts-code <code>", "GTS code", collectList, []).option("--source <name>", "Source", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.security-clue.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, queryMode: options.queryMode, gtsCodeList: maybeArray(options.gtsCode), source: maybeArray(options.source) })));
732
- ai.command("one-pager").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.one-pager", { securityCode: options.securityCode })));
733
- ai.command("investment-logic").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.investment-logic", { securityCode: options.securityCode })));
734
- ai.command("peer-comparison").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.peer-comparison", { securityCode: options.securityCode })));
735
- 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 ~5 min)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient(async (client) => {
736
- const format = parseOutputFormat(options.format);
737
- const idResult = await client.call("ai.earnings-review.get-id", { securityCode: options.securityCode, period: options.period });
738
- const dataId = idResult?.dataId;
739
- if (!dataId) {
740
- process.stderr.write("Failed to get earnings review ID. The report may not be available yet.\n");
741
- process.exitCode = 1;
742
- return;
743
- }
744
- if (!options.wait) {
745
- process.stderr.write(`Earnings review task submitted. dataId: ${dataId}\n`);
746
- await printData({ dataId, status: "pending", hint: `Run 'gangtise ai earnings-review-check --data-id ${dataId}' in ~2 minutes to get results` }, format, options.output);
747
- return;
748
- }
749
- process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
750
- const outcome = await pollAsyncContent(client, "ai.earnings-review.get-content", dataId, format, options.output);
751
- if (outcome !== "ok") {
752
- if (outcome === "timeout") {
753
- process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai earnings-review-check --data-id ${dataId}\n`);
754
- }
755
- process.exitCode = 1;
756
- }
757
- }));
758
- ai.command("earnings-review-check").requiredOption("--data-id <id>", "dataId from earnings-review").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient((client) => checkAsyncContent(client, "ai.earnings-review.get-content", options.dataId, parseOutputFormat(options.format), options.output)));
759
- ai.command("theme-tracking").requiredOption("--theme-id <id>", "Theme ID (use 'reference concept-search')").requiredOption("--date <date>", "Date (yyyy-MM-dd)", dateArg("--date")).option("--type <name>", "Report type: morning/night", collectList, []).option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => {
760
- const typeList = options.type.length ? options.type : undefined;
761
- return client.call("ai.theme-tracking", { themeId: options.themeId, date: options.date, type: typeList });
762
- }));
763
- ai.command("research-outline").requiredOption("--security-code <code>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.research-outline", { securityCode: options.securityCode })));
764
- 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)", dateArg("--start-date")).option("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).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((options) => emit(options, (client) => {
765
- const ALL_CATEGORIES = ["morningBriefing", "noonBriefing", "afternoonFlash", "eveningBriefing"];
766
- return client.call("ai.hot-topic", {
767
- from: parseFrom(options.from),
768
- size: parseSize(options.size),
769
- startDate: options.startDate,
770
- endDate: options.endDate,
771
- categoryList: options.category.length > 0 ? options.category : ALL_CATEGORIES,
772
- withRelatedSecurities: options.withRelatedSecurities !== false,
773
- withCloseReading: options.withCloseReading !== false,
774
- });
775
- }));
776
- ai.command("management-discuss-announcement").requiredOption("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)", dateArg("--report-date")).requiredOption("--security-code <code>", "Security code (e.g. 000001.SZ)").addOption(new Option("--dimension <name>", "Discussion dimension: businessOperation/financialPerformance/developmentAndRisk/all").choices(["businessOperation", "financialPerformance", "developmentAndRisk", "all"]).makeOptionMandatory()).option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.management-discuss-announcement", {
777
- reportDate: options.reportDate,
778
- securityCode: options.securityCode,
779
- discussionDimension: options.dimension,
780
- })));
781
- ai.command("management-discuss-earnings-call").requiredOption("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)", dateArg("--report-date")).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((options) => emit(options, (client) => client.call("ai.management-discuss-earnings-call", {
782
- reportDate: options.reportDate,
783
- securityCode: options.securityCode,
784
- discussionDimension: options.dimension,
785
- })));
786
- ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint text (max 1000 chars)").option("--wait", "Wait for content generation (blocking, up to ~5 min)").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => withClient(async (client) => {
787
- const format = parseOutputFormat(options.format);
788
- const idResult = await client.call("ai.viewpoint-debate.get-id", { viewpoint: options.viewpoint });
789
- const dataId = idResult?.dataId;
790
- if (!dataId) {
791
- process.stderr.write("Failed to get viewpoint debate ID.\n");
792
- process.exitCode = 1;
793
- return;
794
- }
795
- if (!options.wait) {
796
- process.stderr.write(`Viewpoint debate task submitted. dataId: ${dataId}\n`);
797
- await printData({ dataId, status: "pending", hint: `Run 'gangtise ai viewpoint-debate-check --data-id ${dataId}' in ~2 minutes to get results` }, format, options.output);
798
- return;
799
- }
800
- process.stderr.write(`Got dataId: ${dataId}, waiting for content generation...\n`);
801
- const outcome = await pollAsyncContent(client, "ai.viewpoint-debate.get-content", dataId, format, options.output);
802
- if (outcome !== "ok") {
803
- if (outcome === "timeout") {
804
- process.stderr.write(`Content not available after ${POLL_MAX_ATTEMPTS} attempts. Try again later with: gangtise ai viewpoint-debate-check --data-id ${dataId}\n`);
805
- }
806
- process.exitCode = 1;
807
- }
808
- }));
809
- 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)));
810
- const STOCK_SUMMARY_MAX_SECURITIES = 6000;
811
- 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 ${STOCK_SUMMARY_MAX_SECURITIES} per call; market keywords are NOT supported by this endpoint`, collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
812
- if (!options.security.length)
813
- throw new ValidationError(`--security is required: pass one or more security codes (A-share / HK), up to ${STOCK_SUMMARY_MAX_SECURITIES} per call`);
814
- if (options.security.length > STOCK_SUMMARY_MAX_SECURITIES) {
815
- throw new ValidationError(`ai stock-summary: ${options.security.length} securities in one call — the endpoint takes at most ${STOCK_SUMMARY_MAX_SECURITIES}. Split the codes into batches of at most ${STOCK_SUMMARY_MAX_SECURITIES} and run one call per batch.`);
816
- }
817
- checkMarketKeywords(options.security, [], "ai stock-summary");
818
- return emit(options, (client) => client.call("ai.stock-summary.list", { securityList: maybeArray(options.security) }));
819
- });
820
- const reference = new Command("reference").description("Reference data APIs");
821
- reference.command("securities-search").requiredOption("--keyword <text>", "Search keyword (name/code/pinyin/English)").option("--category <type>", "Category: stock/dr/index/fund", 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.securities-search", {
822
- keyword: options.keyword,
823
- category: parseChoiceList(options.category, "--category", ["stock", "dr", "index", "fund"]),
824
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
825
- })));
826
- reference.command("constant-category").description("List constant categories and which API params accept them").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.constant-category")));
827
- reference.command("constant-list").requiredOption("--category <code>", "Category code from 'reference constant-category' (e.g. citicIndustry/swIndustry/regionCategory)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.constant-list", { category: options.category })));
828
- reference.command("concept-search").requiredOption("--keyword <text>", "Search keyword (name/pinyin/group name)").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.concept-search", {
829
- keyword: options.keyword,
830
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
831
- })));
832
- reference.command("sector-search").option("--keyword <text>", "Search keyword (name/pinyin)").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.sector-search", {
833
- keyword: options.keyword,
834
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
835
- })));
836
- reference.command("sector-constituents").requiredOption("--sector-id <id>", "Sector ID from 'reference sector-search'").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("reference.sector-constituents", { sectorId: options.sectorId })));
837
- reference.command("chiefs-search").requiredOption("--keyword <text>", "Search keyword (chief name / institution / team)").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.chiefs-search", {
838
- keyword: options.keyword,
839
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
840
- })));
841
- reference.command("institution-search").requiredOption("--keyword <text>", "Search keyword (institution name / abbreviation)").option("--category <name>", "Category: domesticBroker/foreignInstitution/leadInstitution/opinionInstitution/foreignOpinionInstitution (repeat); omit for all", 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.institution-search", {
842
- keyword: options.keyword,
843
- categoryList: parseChoiceList(options.category, "--category", ["domesticBroker", "foreignInstitution", "leadInstitution", "opinionInstitution", "foreignOpinionInstitution"]),
844
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
845
- })));
846
- 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", {
847
- keyword: options.keyword,
848
- category: parseChoiceList(options.category, "--category", ["listedCompany", "broker", "government", "media"]),
849
- top: parseNumberOption(options.top, "--top", { integer: true, min: 1, max: 10 }),
850
- })));
851
- program.addCommand(reference);
852
- const vault = new Command("vault").description("Vault APIs");
853
- 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>", "Start time", datetimeArg("--start-time")).option("--end-time <datetime>", "End time", datetimeArg("--end-time")).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((options) => emit(options, (client) => client.call("vault.drive.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, fileTypeList: options.fileType.length ? options.fileType : undefined, spaceTypeList: options.spaceType.length ? options.spaceType : undefined }), { endpointKey: "vault.drive.list", idField: "fileId" }));
854
- addDownloadCommand(vault, { endpointKey: "vault.drive.download", name: "drive-download", idOption: "--file-id", idField: "fileId", fallbackPrefix: "file", titleListEndpoint: "vault.drive.list" });
855
- const driveSpaceOption = (help) => new Option("--space-type <n>", help).choices(["1", "2"]).default("1");
856
- vault.command("drive-folder-list").description("List the direct subfolders and files of a drive folder (free)")
857
- .addOption(driveSpaceOption("Space: 1=my drive 2=tenant drive"))
858
- .option("--parent-id <id>", "Folder ID in that space; omit (or 'root') for the space's root. A folder of the other space is rejected with 100003")
859
- .option("--format <format>", "Output format", "json").option("--output <path>")
860
- .action((options) => emit(options, (client) => client.call("vault.drive.folder-list", { spaceType: Number(options.spaceType), parentId: options.parentId })));
861
- vault.command("drive-upload").description("Upload a file to the AI drive (free; max 100MB per file; trial accounts max 500MB/day)")
862
- .requiredOption("--file <path>", "File to upload")
863
- .addOption(driveSpaceOption("Target space: 1=my drive 2=tenant drive (shared with your whole tenant)"))
864
- .option("--folder-id <id>", "Target folder ID in that space; omit (or 'root') for the space's root")
865
- .option("--title <name>", "Name in the drive, max 200 characters; defaults to the local file name")
866
- .option("--format <format>", "Output format", "json").option("--output <path>")
867
- .action((options) => emit(options, (client) => uploadDriveFile(client, options.file, { spaceType: Number(options.spaceType), folderId: options.folderId, title: options.title })));
868
- vault.command("drive-create-folder").description("Create a drive folder (free). Same-name folders are allowed, so re-running creates a second one")
869
- .requiredOption("--name <name>", "Folder name, max 200 characters (230004 past that)")
870
- .addOption(driveSpaceOption("Space: 1=my drive 2=tenant drive (shared with your whole tenant)"))
871
- .option("--parent-id <id>", "Parent folder ID in that space; omit (or 'root') for the root")
872
- .option("--format <format>", "Output format", "json").option("--output <path>")
873
- .action((options) => emit(options, (client) => client.call("vault.drive.create-folder", { folderName: options.name, spaceType: Number(options.spaceType), parentId: options.parentId })));
874
- vault.command("drive-rename").description("Rename a drive file or folder (free)")
875
- .addOption(new Option("--type <type>", "What --id names").choices(["file", "folder"]).makeOptionMandatory())
876
- .requiredOption("--id <id>", "File or folder ID")
877
- .requiredOption("--name <name>", "New name, max 200 characters")
878
- .option("--format <format>", "Output format", "json").option("--output <path>")
879
- .action((options) => emit(options, (client) => client.call("vault.drive.rename", { type: options.type, id: options.id, name: options.name })));
880
- vault.command("drive-move-file").description("Move drive files into a folder of the SAME space (free)")
881
- .requiredOption("--file-id <id>", "File ID (repeat or comma-separate)", collectList)
882
- .requiredOption("--target-folder-id <id>", "Destination folder ID, or 'root'. Files of the other space land in failList (空间不一致)")
883
- .option("--format <format>", "Output format", "json").option("--output <path>")
884
- .action((options) => emit(options, async (client) => {
885
- const data = await client.call("vault.drive.move-file", { fileIdList: options.fileId, targetFolderId: options.targetFolderId });
886
- flagFailedItems(data, "vault drive-move-file");
887
- return data;
888
- }));
889
- vault.command("drive-move-folder").description("Move a drive folder under another folder of the SAME space (free)")
890
- .requiredOption("--folder-id <id>", "Folder ID to move")
891
- .requiredOption("--target-parent-id <id>", "Destination parent folder ID, or 'root'. Not the folder itself, one of its subfolders, or the other space (230005)")
892
- .option("--format <format>", "Output format", "json").option("--output <path>")
893
- .action((options) => emit(options, (client) => client.call("vault.drive.move-folder", { folderId: options.folderId, targetParentId: options.targetParentId })));
894
- vault.command("drive-copy").description("Copy files to the OTHER space: my drive <-> tenant drive (free; same-space copies are rejected)")
895
- .requiredOption("--file-id <id>", "Source file ID (repeat or comma-separate)", collectList)
896
- .requiredOption("--target-folder-id <id>", "Destination folder ID in the other space, or 'root' for that space's root")
897
- .option("--format <format>", "Output format", "json").option("--output <path>")
898
- .action((options) => emit(options, async (client) => {
899
- const data = await client.call("vault.drive.copy", { copyType: "file", fileIdList: options.fileId, targetFolderId: options.targetFolderId });
900
- flagFailedItems(data, "vault drive-copy");
901
- return data;
902
- }));
903
- vault.command("drive-delete-file").description("Delete drive files — irreversible (free)")
904
- .requiredOption("--file-id <id>", "File ID to delete (repeat or comma-separate)", collectList)
905
- .option("--yes", "Required: confirm the deletion")
906
- .option("--format <format>", "Output format", "json").option("--output <path>")
907
- .action(async (options) => {
908
- assertConfirmed("vault.drive.delete-file", Boolean(options.yes), options.fileId.join("、"));
909
- await emit(options, async (client) => {
910
- const data = await client.call("vault.drive.delete-file", { fileIdList: options.fileId });
911
- flagFailedItems(data, "vault drive-delete-file");
912
- return data;
913
- });
914
- });
915
- vault.command("drive-delete-folder").description("Delete a drive folder AND every subfolder and file inside it — irreversible (free)")
916
- .requiredOption("--folder-id <id>", "Folder ID to delete")
917
- .option("--yes", "Required: confirm the deletion")
918
- .option("--format <format>", "Output format", "json").option("--output <path>")
919
- .action(async (options) => {
920
- assertConfirmed("vault.drive.delete-folder", Boolean(options.yes), options.folderId);
921
- await emit(options, (client) => client.call("vault.drive.delete-folder", { folderId: options.folderId }));
922
- });
923
- vault.command("record-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>", "Start time", datetimeArg("--start-time")).option("--end-time <datetime>", "End time", datetimeArg("--end-time")).option("--keyword <text>").option("--category <name>", "Recording type: upload/link/mobile/gtNote/pc/share", collectList, []).option("--space-type <number>", "Space type: 1=my records / 2=tenant records", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.record.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, categoryList: maybeArray(options.category), spaceTypeList: options.spaceType.length ? options.spaceType : undefined }), { endpointKey: "vault.record.list", idField: "recordId" }));
924
- addDownloadCommand(vault, { endpointKey: "vault.record.download", name: "record-download", idOption: "--record-id", idField: "recordId", fallbackPrefix: "record", contentTypeDescription: "Content type: original/asr/summary", titleListEndpoint: "vault.record.list" });
925
- vault.command("my-conference-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>", "Start time", datetimeArg("--start-time")).option("--end-time <datetime>", "End time", datetimeArg("--end-time")).option("--keyword <text>").option("--research-area <id>", "Research area ID: citicIndustry code (1008001xx) or gangtiseIndustry direction code (122000xxx: macro/strategy/fixed-income/quant/overseas). swIndustry (104xx0000) returns 0 here", collectList, []).option("--security <code>", "Security code", collectList, []).option("--institution <id>", "Institution ID", collectList, []).option("--category <name>", "Conference category: earningsCall/strategyMeeting/fundRoadshow/shareholdersMeeting/maMeeting/specialMeeting/companyAnalysis/industryAnalysis/other", collectList, []).option("--source <number>", "Recording source: 1=企微会议助理 2=会议服务微信群 (repeat)", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.my-conference.list", { from: parseFrom(options.from), size: parseSize(options.size), startTime: options.startTime, endTime: options.endTime, keyword: options.keyword, researchAreaList: maybeArray(options.researchArea), securityList: maybeArray(options.security), institutionList: maybeArray(options.institution), categoryList: maybeArray(options.category), sourceList: options.source.length ? options.source : undefined }), { endpointKey: "vault.my-conference.list", idField: "conferenceId" }));
926
- addDownloadCommand(vault, { endpointKey: "vault.my-conference.download", name: "my-conference-download", idOption: "--conference-id", idField: "conferenceId", fallbackPrefix: "conference", contentTypeDescription: "Content type: asr/summary", titleListEndpoint: "vault.my-conference.list" });
927
- vault.command("wechat-message-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>", "Start time", datetimeArg("--start-time")).option("--end-time <datetime>", "End time", datetimeArg("--end-time")).option("--keyword <text>").option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, []).option("--wechat-group-id <id>", "WeChat group ID", collectList, []).option("--industry <id>", "Industry ID -- citicIndustry codes (1008001xx) only; swIndustry codes and unknown values are rejected with 100005", collectList, []).option("--category <name>", "Message type: text/image/documents/url", collectList, []).option("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-message.list", buildWechatMessageListBody(options))));
928
- vault.command("wechat-chatroom-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--room-name <name>", "WeChat group name; repeat or comma-separate for multiple names", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.wechat-chatroom.list", buildWechatChatroomListBody(options))));
929
- vault.command("stock-pool-list").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.list", {})));
930
- vault.command("stock-pool-stocks").option("--pool-id <id>", "Pool ID; repeat for multiple; omit (or 'all') for all pools", collectList).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.stocks", buildStockPoolStocksBody(options))));
931
- function assertConfirmed(endpointKey, confirmed, target) {
932
- const destructive = ENDPOINTS[endpointKey]?.destructive;
933
- if (!destructive || confirmed)
934
- return;
935
- throw new ValidationError(`${destructive.warning} 确认要对 ${target} 执行就加上 --yes。`);
936
- }
937
- vault.command("stock-pool-create").requiredOption("--name <name>", "New pool name; max 10 characters (a CJK character counts as 1) and must differ from every existing pool name. A duplicate is rejected with 230006, an over-long name with 230007. Max 30 pools per account (230003)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.create", { poolName: options.name })));
938
- vault.command("stock-pool-rename").requiredOption("--pool-id <id>", "Pool ID from 'stock-pool-list'").requiredOption("--name <name>", "New pool name; same rules as stock-pool-create (max 10 characters, must differ from every existing pool name). Renaming a pool to its own current name succeeds").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("vault.stock-pool.rename", { poolId: options.poolId, poolName: options.name })));
939
- vault.command("stock-pool-add-stock").requiredOption("--pool-id <id>", "Target pool ID from 'stock-pool-list'").requiredOption("--security <code>", "Security code, e.g. 600519.SH (repeat or comma-separate)", collectList).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, async (client) => {
940
- const data = await client.call("vault.stock-pool.add-stock", { poolId: options.poolId, securityCodeList: options.security });
941
- flagFailedItems(data, "vault stock-pool-add-stock");
942
- return data;
943
- }));
944
- vault.command("stock-pool-remove-stock").requiredOption("--pool-id <id>", "Target pool ID from 'stock-pool-list'").requiredOption("--security <code>", "Security code, e.g. 600519.SH (repeat or comma-separate)", collectList).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, async (client) => {
945
- const data = await client.call("vault.stock-pool.remove-stock", { poolId: options.poolId, securityCodeList: options.security });
946
- flagFailedItems(data, "vault stock-pool-remove-stock");
947
- return data;
948
- }));
949
- vault.command("stock-pool-delete").requiredOption("--pool-id <id>", "Pool ID to delete; repeat or comma-separate for multiple", collectList).option("--yes", "Required: confirm the deletion").option("--format <format>", "Output format", "table").option("--output <path>").action(async (options) => {
950
- assertConfirmed("vault.stock-pool.delete", Boolean(options.yes), options.poolId.join("、"));
951
- await emit(options, async (client) => {
952
- const data = await client.call("vault.stock-pool.delete", { poolIdList: options.poolId });
953
- flagFailedItems(data, "vault stock-pool-delete");
954
- return data;
955
- });
956
- });
957
- program.addCommand(vault);
958
- program.addCommand(ai);
959
- const alternative = new Command("alternative").description("Alternative data APIs");
960
- alternative.command("edb-search").requiredOption("--keyword <text>", "Search keyword (e.g. '空调')").option("--limit <number>", "Max results (default: 100, max: 200)", "100").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.edb-search", {
961
- keyword: options.keyword,
962
- limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 200 }),
963
- })));
964
- alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (repeat, max 10)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).requiredOption("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
965
- const format = parseOutputFormat(options.format);
966
- const raw = await client.call("alternative.edb-data", {
967
- indicatorIdList: options.indicatorId,
968
- startDate: options.startDate,
969
- endDate: options.endDate,
970
- });
971
- let data = raw;
972
- if (raw && Array.isArray(raw.fieldList) && Array.isArray(raw.dataList)) {
973
- const fields = raw.fieldList;
974
- assertColumnarHeader(fields, raw);
975
- const list = raw.dataList.map((row) => zipFieldRow(fields, row, raw));
976
- data = { list, total: list.length };
977
- }
978
- await printData(data, format, options.output);
979
- }));
980
- alternative.command("concept-info").description("Concept profile: definition, investment logic, industry space, competitive landscape (50 credits/call)").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--full", "Also return catalyst events (keyEvents) via the v1 endpoint — 500 credits/call instead of 50").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call(options.full ? "alternative.concept-info-full" : "alternative.concept-info", { conceptId: options.conceptId })));
981
- alternative.command("concept-securities").description("Concept constituents, grouped (50 credits/call; free when the concept has none)").requiredOption("--concept-id <id>", "Concept (theme index) ID, e.g. 121000130 机器人; discover via 'gangtise reference concept-search'").option("--full", "Also return the key-stock flag (isKey) and inclusion reason via the v1 endpoint — 500 credits/call instead of 50").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call(options.full ? "alternative.concept-securities-full" : "alternative.concept-securities", { conceptId: options.conceptId })));
982
- program.addCommand(alternative);
983
- function flagDropped(rows, data, requestedSecurities, requestedIndicators) {
984
- if (isEmptyMatrix(data)) {
985
- 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");
986
- return;
987
- }
988
- const { securities, indicators } = droppedFromMatrix(data, requestedSecurities, requestedIndicators);
989
- if (securities.length === 0 && indicators.length === 0)
990
- return;
991
- if (rows && typeof rows === "object" && !Array.isArray(rows)) {
992
- const rec = rows;
993
- rec.partial = true;
994
- if (indicators.length > 0)
995
- rec.omittedIndicators = indicators;
996
- if (securities.length > 0)
997
- rec.omittedSecurities = securities;
998
- }
999
- const parts = [];
1000
- if (indicators.length > 0)
1001
- parts.push(`indicators ${indicators.join(", ")}`);
1002
- if (securities.length > 0)
1003
- parts.push(`securities ${securities.join(", ")}`);
1004
- 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`);
1005
- }
1006
- function requireIndicatorScope(indicators, securities) {
1007
- const missing = [indicators.length === 0 ? "--indicator" : "", securities.length === 0 ? "--security" : ""].filter(Boolean);
1008
- if (missing.length > 0) {
1009
- throw new ValidationError(`${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} required (repeat the flag for multiple values)`);
1010
- }
1011
- }
1012
- const indicator = new Command("indicator").description("Data indicator (EDE) APIs: search codes, cross-section, time-series, screener");
1013
- indicator.command("search").requiredOption("--keyword <text>", "Search keyword, e.g. '收盘价' '成交量' '营业收入' (not free-form questions)").option("--limit <number>", "Max results (default: 50, max: 100)", "50").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
1014
- const format = parseOutputFormat(options.format);
1015
- const raw = await client.call("indicator.search", {
1016
- keyword: options.keyword,
1017
- limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 100 }),
1018
- });
1019
- await printData(unwrapIndicatorData(raw), format, options.output);
1020
- }));
1021
- 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) => {
1022
- const format = parseOutputFormat(options.format);
1023
- requireIndicatorScope(options.indicator, options.security);
1024
- const raw = await client.call("indicator.cross-section", buildIndicatorCrossSectionBody(options));
1025
- const data = requireIndicatorMatrix(raw);
1026
- const rows = flattenCrossSection(data, options.keyBy);
1027
- flagDropped(rows, data, options.security, options.indicator);
1028
- await printData(rows, format, options.output);
1029
- }));
1030
- indicator.command("time-series").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("--start-date <date>", "Start date (yyyy-MM-dd)", dateArg("--start-date")).requiredOption("--end-date <date>", "End date (yyyy-MM-dd)", dateArg("--end-date")).option("--calendar-type <type>", "Calendar: ND=natural TD=trading WD=weekday. Omit it and the CLI picks: a free 'indicator search' reads each parameterList and sends TD only when EVERY indicator is trading-day typed, otherwise it leaves the server on ND (report-period indicators land on dates a trading calendar lacks, and TD would answer them with an all-null grid). An explicit value is sent as given, with no lookup").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/securityCode, unique & order-stable for batch mapping").choices(["name", "code"]).default("name")).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
1031
- const format = parseOutputFormat(options.format);
1032
- requireIndicatorScope(options.indicator, options.security);
1033
- const body = buildIndicatorTimeSeriesBody(options);
1034
- if (!body.calendarType) {
1035
- const resolved = await resolveCalendarType(client, options.indicator);
1036
- if (resolved) {
1037
- body.calendarType = resolved;
1038
- process.stderr.write(`[gangtise] note: every requested indicator takes tradeDate, so this series was fetched on the trading-day calendar (calendarType=TD) instead of the server default ND — no all-null non-trading rows, and fewer cells against the 30000 cap. Pass --calendar-type ND to override.\n`);
1039
- }
1040
- }
1041
- const raw = await client.call("indicator.time-series", body);
1042
- const data = requireIndicatorMatrix(raw);
1043
- const rows = flattenTimeSeries(data, options.keyBy, options.security);
1044
- flagDropped(rows, data, options.security, options.indicator);
1045
- await printData(rows, format, options.output);
1046
- }));
1047
- 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'. Bare 'F1:' (nothing after the colon) declares that 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_sctr, scr_isin …), plus div_cash_paid_ratio / div_cash_yr (add 'F1:fiscalYear=YYYY' too) and pty_shr_reg. It composes with real params, so 'F1:' + 'F1:scale=8' keeps the scale", 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) => {
1048
- const format = parseOutputFormat(options.format);
1049
- requireIndicatorScope(options.indicator, options.security);
1050
- const bindings = parseScreenerIndicators(options.indicator, options.indicatorParam, options.expression);
1051
- const raw = await client.call("indicator.screener", buildIndicatorScreenerBody(options));
1052
- const data = requireIndicatorMatrix(raw);
1053
- const rows = flattenCrossSection(data, options.keyBy);
1054
- const filteredOn = screenerExpressionFields(options.expression);
1055
- const unbound = checkScreenerBindings(data, bindings, options.expression);
1056
- if (data.securityCodeList.length === 0) {
1057
- 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");
1058
- }
1059
- if (unbound.length > 0) {
1060
- const rec = rows;
1061
- rec.partial = true;
1062
- rec.omittedIndicators = unbound.map((field) => bindings.find((b) => b.field === field)?.indicatorCode ?? field);
1063
- const alsoFiltered = unbound.filter((field) => filteredOn.includes(field));
1064
- const filterNote = alsoFiltered.length > 0
1065
- ? ` The expression also filters on ${alsoFiltered.join(", ")}, so that condition was applied to none of these rows — another branch of the expression could still have matched them legitimately, but verify before relying on that filter.`
1066
- : "";
1067
- process.stderr.write(`[gangtise] warning: ${unbound.join(", ")} produced no column at all — those output values are missing. An indicator the server merely has no data for still returns a null column, so this normally means the bound code was not recognised; check it against 'gangtise indicator search'.${filterNote} Result marked partial (exit 3).\n`);
1068
- }
1069
- await printData(rows, format, options.output);
1070
- }));
1071
- program.addCommand(indicator);
1072
- const tool = new Command("tool").description("Research tool APIs: PDF parsing");
1073
- tool.command("file-parse").description("Parse a PDF into Markdown + images (async; 0.8 credits/page, billed at submit)")
1074
- .requiredOption("--file <path>", "PDF to upload (max 100MB / 500 pages)")
1075
- .option("--wait", "Wait for the parse to finish and save the result ZIP (blocking, up to ~5 min)")
1076
- .option("--output <path>", "Where to save the result ZIP (used with --wait)")
1077
- .action((options) => withClient(async (client) => {
1078
- const taskId = await submitFileParse(client, options.file);
1079
- if (!options.wait) {
1080
- process.stderr.write(`File parse task submitted. taskId: ${taskId}\n`);
1081
- process.stdout.write(`${JSON.stringify({ taskId, status: "pending", hint: `Run 'gangtise tool file-parse-check --task-id ${taskId}' in ~3 minutes to download the result ZIP` })}\n`);
1082
- return;
1083
- }
1084
- process.stderr.write(`Got taskId: ${taskId}, waiting for the parse to finish...\n`);
1085
- if (await pollFileParseResult(client, taskId, options.output) !== "ok") {
1086
- 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`);
1087
- process.exitCode = 1;
1088
- }
1089
- }));
1090
- tool.command("file-parse-check").description("Download a finished file-parse result ZIP by taskId (free)")
1091
- .requiredOption("--task-id <id>", "taskId from 'tool file-parse'")
1092
- .option("--output <path>", "Where to save the result ZIP")
1093
- .action((options) => withClient(async (client) => {
1094
- if (await fetchFileParseResult(client, options.taskId, options.output) === "pending") {
1095
- process.stdout.write(`${JSON.stringify({ taskId: options.taskId, status: "pending", hint: "Parse not finished yet, retry in ~1 minute" })}\n`);
1096
- }
1097
- }));
1098
- tool.command("web-search").description("Search the public web for research: deduped, source-tiered (T0-T3) results with optional page content (1 credit/call)")
1099
- .requiredOption("--query <text>", "Search query, 1-200 chars. Sent verbatim — no intent rewriting server-side, so spell out what you want")
1100
- .option("--size <number>", "Results to return, 1-20 (1-5 with --include-content)", "10")
1101
- .addOption(new Option("--freshness <window>", "Time window over publishTime; results with no resolvable publish date are dropped by day/week/month").choices(["day", "week", "month", "none"]))
1102
- .addOption(new Option("--min-tier <tier>", "Lowest source tier to keep; T3 = no filtering").choices(["T0", "T1", "T2", "T3"]))
1103
- .option("--site <domain>", "Restrict to a registered domain or subdomain, e.g. csrc.gov.cn (repeatable, max 10, OR-ed)", collectList, [])
1104
- .option("--include-content", "Return page body as Markdown; caps --size at 5")
1105
- .option("--max-content-chars <number>", "Max chars per body, 1000-20000 (only with --include-content)")
1106
- .option("--format <format>", "Output format", "table").option("--output <path>")
1107
- .action((options) => emit(options, (client) => {
1108
- const size = parseNumberOption(options.size, "--size", { integer: true, min: 1, max: 20 });
1109
- if (options.includeContent && size > 5)
1110
- throw new ValidationError(`--size ${size} with --include-content: this endpoint caps size at 5 when page bodies are requested. Lower --size, or drop --include-content to fetch up to 20 snippets.`);
1111
- const sites = [...new Set(options.site)];
1112
- if (sites.length > 10)
1113
- throw new ValidationError(`--site takes at most 10 distinct domains, got ${sites.length}`);
1114
- return client.call("tool.web-search", {
1115
- query: options.query, size, freshness: options.freshness, minTier: options.minTier,
1116
- siteList: maybeArray(sites), includeContent: options.includeContent || undefined,
1117
- maxContentChars: parseOptionalNumberOption(options.maxContentChars, "--max-content-chars", { integer: true, min: 1000, max: 20000 }),
1118
- });
1119
- }));
1120
- program.addCommand(tool);
1121
- 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("--yes", "Confirm an irreversible endpoint (required for the ones marked destructive)").option("--format <format>", "Output format", "json").option("--output <path>").action(async (endpointKey, options) => {
1122
- const endpoint = ENDPOINTS[endpointKey];
1123
- if (!endpoint) {
1124
- throw new ConfigError(`Unknown endpoint key: ${endpointKey}`);
1125
- }
1126
- assertConfirmed(endpointKey, Boolean(options.yes), endpointKey);
1127
- const format = parseOutputFormat(options.format);
1128
- const client = await createClient({ format, output: options.output });
1129
- let body;
1130
- if (options.body) {
1131
- try {
1132
- body = JSON.parse(options.body);
1133
- }
1134
- catch {
1135
- throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
1136
- }
1137
- }
1138
- if (endpoint.kind === "download") {
1139
- if (body !== undefined && endpoint.method !== "POST") {
1140
- throw new ValidationError(`--body is not supported for GET download endpoints (use --query key=value); ${endpointKey} is kind=download`);
1141
- }
1142
- await runDownload(client, endpointKey, options.query, {
1143
- output: options.output,
1144
- fallbackName: "download.bin",
1145
- body,
1146
- });
1147
- return;
1148
- }
1149
- if (Object.keys(options.query).length > 0) {
1150
- throw new ValidationError(`--query is not supported for JSON endpoints (use --body '{...}'); ${endpointKey} is kind=json`);
1151
- }
1152
- try {
1153
- const data = await client.call(endpointKey, body);
1154
- if (endpoint.itemFailures)
1155
- flagFailedItems(data, `raw call ${endpointKey}`);
1156
- await printData(data, format, options.output);
1157
- }
1158
- finally {
1159
- await client.rowSink?.abort();
1160
- }
1161
- })).addCommand(new Command("list").description("List all registered endpoint keys (for use with 'raw call')").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => printData(listEndpoints(), parseOutputFormat(options.format), options.output)));
1162
33
  async function checkForUpdate(timeoutMs = 2000) {
1163
34
  try {
1164
35
  const response = await fetch("https://registry.npmjs.org/gangtise-openapi-cli/latest", { signal: AbortSignal.timeout(timeoutMs) });
@@ -1180,7 +51,7 @@ function reportFatal(error) {
1180
51
  return;
1181
52
  }
1182
53
  leaving = true;
1183
- process.exitCode = 1;
54
+ markFailed();
1184
55
  let pending = 1;
1185
56
  const leave = () => { if (--pending === 0)
1186
57
  process.exit(1); };
@@ -1196,7 +67,7 @@ process.on("unhandledRejection", reportFatal);
1196
67
  const READER_GONE = new Set(["EPIPE", "ERR_STREAM_DESTROYED", "EBADF"]);
1197
68
  process.stdout.on("error", (error) => {
1198
69
  if (error?.code && READER_GONE.has(error.code))
1199
- process.exit(0);
70
+ process.exit(decidedExitCode());
1200
71
  reportFatal(error);
1201
72
  });
1202
73
  async function main() {
@@ -1214,16 +85,16 @@ async function main() {
1214
85
  const hint = error.hint ? ` ${error.hint}` : "";
1215
86
  const trace = error.traceId ? ` [trace ${error.traceId}]` : "";
1216
87
  process.stderr.write(`API error${error.code ? ` (${error.code})` : ""}${trace}: ${error.message}${hint}\n`);
1217
- process.exitCode = 1;
88
+ markFailed();
1218
89
  return;
1219
90
  }
1220
91
  if (error instanceof Error) {
1221
92
  process.stderr.write(`${error.name}: ${error.message}\n`);
1222
- process.exitCode = 1;
93
+ markFailed();
1223
94
  return;
1224
95
  }
1225
96
  process.stderr.write("Unknown error\n");
1226
- process.exitCode = 1;
97
+ markFailed();
1227
98
  }
1228
99
  }
1229
100
  void main();