gangtise-openapi-cli 0.19.0 → 0.21.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/README.md CHANGED
@@ -1,9 +1,53 @@
1
1
  # Gangtise OpenAPI CLI
2
2
 
3
- 一个可直接调用 Gangtise OpenAPI 获取金融数据的命令行工具,同时提供Agent Skill。
3
+ 一个可直接调用 Gangtise OpenAPI 获取全量金融信息的命令行工具,同时提供Agent Skill。
4
4
 
5
5
  ## Changelog
6
6
 
7
+ ### v0.21.0 — 2026-06-29
8
+
9
+ **行为变更(注意)**
10
+ - ⚠️ `vault wechat-chatroom-list` 省略 `--size` 现在**拉全量**(此前默认只返回 20 条)。该接口不返回 `total`,CLI 改为串行翻页(翻到不满页为止,单页上限 50);传 `--size N` 仍只取前 N 条。依赖"默认 20 条"的脚本会拿到全部群。
11
+
12
+ **修复**
13
+ - `quote day-kline --security all` 等大结果集用默认 `table` 格式输出时不再因 `Math.max(...大数组)` 撑爆调用栈崩溃(`RangeError`);`renderTable` 改用 reduce 计算列宽
14
+ - CSV 导出:含回车符 `\r` 的字段现在正确加引号(RFC 4180);`table` / `markdown` 的多行字段折叠换行,保持表格对齐
15
+ - 下载文件名剥离控制字符 / NUL,避免 `fs.writeFile` 报错
16
+
17
+ **修复(安全)**
18
+ - token 缓存文件(`~/.config/gangtise/token.json`)改为临时文件 + 原子 `rename` 写入:从第一字节即 `0600`,消除"旧文件宽松权限残留"与"崩溃截断"两个隐患
19
+
20
+ **内部 / 工程**
21
+ - 依赖 `vitest` 升级到 3.2.6(修复 dev-only 安全告警);新增 `npm run typecheck`;测试 257 → 272
22
+
23
+ ### v0.20.0 — 2026-06-26
24
+
25
+ **新增接口**
26
+ - `insight announcement-us list` / `download` — 美股公司公告列表与下载(`--security TSLA.O`、`--category`〔分类用 `reference constant-list --category usShareAnnouncementCategory`,美股独立的 `103980xxx` 段〕、`--search-type`、`--rank-type`、下载 `--file-type 1` 原始 PDF / `2` Markdown);自动翻页,单页上限 50
27
+ - `ai stock-summary` — 个股看点(精炼投研总结):`--security` 传具体代码(A股/港股,可重复,单次最多 6000)或市场关键词 `aShares` / `hkStocks` 拉全市场;无看点的证券不返回、不扣分
28
+ - `fundamental income-statement-us` / `balance-sheet-us` / `cash-flow-us` — 美股三大财务报表(参数同其他财报:`--security-code` / `--period` / `--report-type` / `--fiscal-year` / `--field` 等)
29
+ - `reference chiefs-search` — 首席分析师 ID 搜索(`--keyword` 按姓名/机构/团队匹配,`--top` 默认 10);用于 `insight opinion list --chief` 的入参
30
+
31
+ **变更**
32
+ - `insight announcement-hk download` 新增 `--file-type`(`1` 原始(默认)/ `2` Markdown),此前无格式选项
33
+
34
+ **行为变更(注意)**
35
+ - ⚠️ `auth login` / `auth status` 默认脱敏 access token:`--format json` 输出里 `authorization` 与 `cache.accessToken` 显示为 `<redacted>`,仅保留过期时间 / 用户名 / 产品码 / uid 等非敏感字段。**依赖 `auth login` 原始 token 输出的脚本会拿到 `<redacted>`**,需改用 `auth login --show-token` 获取明文。
36
+
37
+ **修复(安全)**
38
+ - `auth status` / `auth login` token 脱敏:按凭证字段名模式匹配(`token`/`key`/`secret`/`password`/`credential`),覆盖 `apiKey`/`privateKey`/`refreshToken` 等任何可能携带的凭证字段
39
+ - 自愈守卫:同时设 `GANGTISE_TOKEN` + AK/SK 时,注入 token 失效后重新登录不再被旧 token 短路,重试改用登录拿到的新 token
40
+
41
+ **修复(数据正确性 / 健壮性)**
42
+ - ⚠️ **CSV 负数不再被破坏**(影响所有 CSV 导出):此前防公式注入会把负数(如跌幅 `-3.5`)加 `'` 前缀变成文本,Excel/pandas 无法参与计算;现仅对非有限数字的可疑串(`=`/`@`/`-1+cmd` 等)加前缀,合法数字原样输出
43
+ - 自动翻页改为 fail-soft:某页遇不可重试错误(限流 `903301` 等)不再丢弃已取的全部数据,返回已取页 + `partial` / `failedPages` 标记,并在首错后停止继续请求(避免撞限流多烧配额)
44
+ - 下载文件名 fallback(服务端 `Content-Disposition`)补清洗:含 `/`、`:` 等字符的文件名不再写到意外路径
45
+ - `ai stock-summary` / `ai knowledge-batch` 缺 `--security` / `--query` 时本地报错,不再发空请求(stock-summary 借此避免被后台当全市场误扣积分)
46
+ - `ai hot-topic` `--no-with-related-securities` / `--no-with-close-reading` 改为显式发 `false`(语义更明确,不依赖"字段缺失=排除"的隐含约定)
47
+
48
+ **修复(indicator 适配 EDE 后台新结构)**
49
+ - `indicator cross-section` / `time-series` 适配后台改版的返回结构(字段名加 `List` 后缀 `securityCodeList/indicatorCodeList/…`、截面 `values` 改二维 `[指标][证券]`):此前后台改结构后 CLI 拍平失配、退化成原始矩阵,现恢复 `{date, security, name, 指标:值}` 宽表。配合后台同步变化——无数据从 `999999` 报错改为返回 `null`(截面不再 500、不丢行),缺必填参数从笼统 `410106` 改为直接指明缺哪个参数
50
+
7
51
  ### v0.19.0 — 2026-06-24
8
52
 
9
53
  **新增接口(Indicator · 证券级数据指标 EDE)**
@@ -36,120 +80,7 @@
36
80
  - `--industry` 用 `citicIndustry` 码(`1008001xx`,全命令通用);`--research-area` 用 `gangtiseIndustry` 码(行业 `1008001xx` + 宏观/策略/固收/金工/海外等方向 `122000xxx`)。详见 `gangtise-openapi/references/commands/reference-and-lookup.md`
37
81
  - 日程类 `--location`(domesticCity)服务端过滤已生效(v0.16.0 时曾未生效)
38
82
 
39
- ### v0.16.0 — 2026-06-12
40
-
41
- **新增接口(参考数据 · 常量查询,均免积分)**
42
- - `reference constant-category` — 查询常量分类:全量导出常量分类及各分类适用于哪些接口的哪些参数(7 个分类:中信/申万/Gangtise 行业、国内城市、A股/港股公告分类、区域)
43
- - `reference constant-list --category <code>` — 查询常量值:按分类导出全量常量(`constantId` / `constantName`,树形分类含 `children` 嵌套)
44
- - `reference concept-search --keyword <kw>` — 查询题材 ID:按名称/拼音/分组名搜索,返回 `conceptId`(供 `alternative concept-info / concept-securities`、`ai theme-tracking` 使用)
45
- - `reference sector-search --keyword <kw>` — 查询板块 ID:返回 `sectorId` + `hierarchy` 层级路径
46
- - `reference sector-constituents --sector-id <id>` — 查询板块成分股:返回该板块全量成分股(`gtsCode` / `gtsName`);注意 sectorId 必须来自 sector-search,题材 conceptId 查不到成分
47
-
48
- **接口变更(Breaking)**
49
- - 移除已被新 API 覆盖的 6 个本地 lookup 子命令及静态数据:`lookup research-area / industry / region / announcement-category / theme-id / industry-code list`,请改用 `reference constant-list` / `reference concept-search` / `reference sector-constituents`(申万行业代码 `821xxx.SWI` 全量:`sector-constituents --sector-id 2000000014`,即申万一级行业指数板块)
50
- - `lookup` 仅保留 2 个 API 未覆盖的本地表:`broker-org` / `meeting-org`
51
- - 路演/调研/策略会/论坛 list 新增 `--location <id>` 按城市过滤(domesticCity 常量 ID;服务端过滤 v0.17.0 起已生效)
52
-
53
- ### v0.15.0 — 2026-05-29
54
-
55
- **新增接口**
56
- - `alternative concept-info` — 题材指数基本信息:返回题材整体画像(定义 / 投资逻辑 / 行业空间 / 竞争格局 / 催化事件)。按 `--concept-id` 查询,仅返回最新截面数据,不支持历史回溯
57
- - `alternative concept-securities` — 题材指数成分股(题材深度 F8):按分组结构返回当前成分股,每只含是否重点个股 `isKey` 与纳入理由 `inclusionReason`。按 `--concept-id` 查询
58
-
59
- **接口变更**
60
- - `quote index-day-kline` 返回字段新增 `securityName`(指数名称,如"上证指数")
61
-
62
- > `--concept-id` 与主题跟踪 `ai theme-tracking --theme-id` 共用同一套题材 ID 体系,可用 `gangtise lookup theme-id list` 按名称查询(如 机器人 → `121000130`)。
63
-
64
- ### v0.14.4 — 2026-05-29
65
-
66
- **Bug fix(全市场 K 线分片容错)**
67
- - `quote day-kline --security all` 等全市场查询的日期分片改为容错:部分分片失败时返回已成功分片的数据并标记 `partial: true` + `failedShards`(失败的日期区间),同时向 stderr 告警;只有全部分片失败才抛错。此前为 fail-fast,单片失败会让整次查询失败,或在异常路径上被误判为空结果。
68
-
69
- ### v0.14.3 — 2026-05-29
70
-
71
- **性能 / 健壮性**
72
- - 标题缓存按端点封顶(5000 条/端点)并清理过期项,修复 `title-cache.json` 无上限增长(曾达 ~58MB)拖慢启动的问题
73
- - 下载接口遇鉴权失效(`8000014` / `8000015`)自动刷新 token 并重试一次(此前仅普通 JSON 调用具备 token 自愈)
74
- - CLI handler 抽出 `emit` / `withClient` 公共封装去除重复样板;CSV 转义逻辑去重;翻页与 K 线分片统一走 `GANGTISE_PAGE_CONCURRENCY` 并发控制
75
- - 补齐多个 core 模块的单元测试
76
-
77
- ### v0.14.2 — 2026-05-22
78
-
79
- **Bug fix(A 股 / HK 全市场 K 线同源问题)**
80
- - `quote day-kline --security all` 由 2 天/片改为 **1 天/片**(A 股全市场单日约 5500 行)
81
- - `quote day-kline-hk --security all` 由 3 天/片改为 **2 天/片**(港股全市场单日约 2770 行)
82
- - 根治性修复:`callKlineWithSharding` 在 `--security all` 路径上,若用户未显式传 `--limit`,强制写入 `limit: 10000`(API 上限),不再走默认 6000——这样即便分片日数估算偏大,每个 shard 也能拿满 10K 行。用户自己传的 `--limit` 仍然保留生效。
83
-
84
- ### v0.14.1 — 2026-05-22
85
-
86
- **Bug fix**
87
- - `quote day-kline-us --security all` 分片由 2 天/片改为 **1 天/片**。美股全市场单日约 5800 行,原 2 天/片会在第一个 shard 命中默认 `--limit 6000` 上限,导致 shard 内第二日数据被截断到几百行。改 1 天/片后每个 shard 数据完整。
88
-
89
- ### v0.14.0 — 2026-05-22
90
-
91
- **新增接口**
92
- - `quote realtime` — 个股实时行情快照,单接口同时覆盖 A 股 / 港股 / 美股;支持代码混合传入或市场关键字(`aShares` / `hkStocks` / `usStocks`)批量查询全市场
93
- - `quote day-kline-us` — 美股历史日 K 线,数据范围 NYSE / NASDAQ / AMEX;支持 `--security all` 全市场(CLI 自动按 1 天/片切分并发拉取,美股全市场单日约 5800 行)
94
-
95
- **接口变更**
96
- - `quote day-kline` / `quote day-kline-hk` 明确仅返回**历史**日 K 线,不包含盘中实时数据;当日数据入库时间:A 股 ~15:30 / 港股 ~16:30(北京时间)。盘中实时请走 `quote realtime`
97
- - `fundamental valuation-analysis` 返回字段移除 `p10` / `p25` / `p75` / `p90`(仍保留 `value` / `percentileRank` / `average` / `median` / `upper1Std` / `lower1Std`)
98
-
99
- ### v0.13.0 — 2026-05-15
100
-
101
- **新增接口**
102
- - `fundamental income-statement-hk / balance-sheet-hk / cash-flow-hk` — 港股三大报表(中国会计准则)
103
- - `alternative edb-search` — 行业指标列表搜索(按关键词匹配指标名称,返回 indicatorId 等元信息)
104
- - `alternative edb-data` — 行业指标时序数据(批量按 indicatorId 拉取时间序列,最多 10 个指标)
105
- - `vault stock-pool-list` — 查询用户自选股股票池列表(poolId / poolName)
106
- - `vault stock-pool-stocks` — 查询股票池证券明细(支持 `--pool-id all` 全量查询)
107
-
108
- **接口变更**
109
- - `fundamental income-statement / balance-sheet / cash-flow / income-statement-quarterly / cash-flow-quarterly` 名称调整为 A股报表(路径不变)
110
- - `ai management-discuss-announcement` `--dimension` 新增 `all` 选项,返回报告中完整的管理层讨论内容(内容可能较长)
111
- - `vault wechat-message-list` 新增 `--security <code>` 参数(按证券代码过滤),返回结果增加 `securityList` 字段
112
-
113
- ### v0.12.0 — 2026-05-10
114
-
115
- **性能 / 架构**
116
- - 翻页并行化:自动翻页接口拉到首页 `total` 后,剩余页通过 `Promise.all` 并发请求(默认并发 5,`GANGTISE_PAGE_CONCURRENCY` 可调)
117
- - 共享 `undici.Agent`:所有请求复用连接池(keep-alive 60s,max 16 连接),避免重复 TLS 握手
118
- - 流式下载:`--output` 指定时二进制响应直接 `pipeline` 到磁盘,不再走内存 `Uint8Array`
119
- - 流式输出:`--format jsonl/csv --output xxx` 且 ≥1000 行时逐行写盘
120
- - Token 内存缓存:Token 在进程内不再每次读盘
121
- - 自动重试:5xx / `ECONNRESET` / `ETIMEDOUT` / `999999` 自动指数退避重试 2 次
122
- - Token 自愈:8000014/8000015 自动重新登录并重试一次
123
- - 异步轮询退避:`earnings-review` / `viewpoint-debate` 轮询从固定 15s 改为 5→8→13→20→30s 指数退避
124
- - K线自动分片:`quote day-kline --security all` 等全市场查询自动按日期切分并发执行
125
- - 标题缓存:原"读全文→改→写全文"改为内存快照 + 原子写入(temp+rename)
126
-
127
- **调试 / 可观测性**
128
- - 新增 `--verbose` / `GANGTISE_VERBOSE=1`:打印每个请求的耗时、状态码、响应字节数到 stderr
129
-
130
- ### v0.11.1 — 2026-05-10
131
-
132
- **新增接口**
133
- - `insight announcement-hk list/download` — 查询/下载港股公告
134
- - `insight foreign-opinion list` — 查询外资机构观点(外资券商)
135
- - `insight independent-opinion list/download` — 查询/下载外资独立分析师观点
136
- - `reference securities-search` — GTS Code 搜索(按名称/代码/拼音多维度匹配证券)
137
-
138
- **接口变更**
139
- - `insight summary download` 新增可选 `--file-type`(`1`=原始内容 / `2`=HTML),仅影响来源为会议平台的纪要
140
- - `insight announcement list/download` 名称调整为"查询A股公告列表/下载A股公告文件"(路径不变)
141
- - `insight opinion list` 名称调整为"查询内资机构观点列表"(路径不变)
142
-
143
- ### v0.11.0 — 2026-04-17
144
-
145
- - 新增 `ai viewpoint-debate` / `viewpoint-debate-check` — 观点PK(异步)
146
- - 新增 `ai management-discuss-announcement` / `management-discuss-earnings-call` — 管理层讨论
147
-
148
- ### v0.10.9 — 2026-04-10
149
-
150
- - 修复信封检测、版本更新检查、端点去重
151
- - 新增 `quote index-day-kline` 指数日K线
152
- - 新增 `vault wechat-message-list` / `wechat-chatroom-list` 群消息
83
+ > 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
153
84
 
154
85
  ## 首次安装
155
86
 
@@ -163,6 +94,12 @@ npm install -g gangtise-openapi-cli
163
94
  gangtise --help
164
95
  ```
165
96
 
97
+ 更新到最新版(`gangtise --version` 会自动与线上版本比对):
98
+
99
+ ```bash
100
+ npm update -g gangtise-openapi-cli
101
+ ```
102
+
166
103
  本地开发:
167
104
 
168
105
  ```bash
@@ -172,36 +109,6 @@ npm install
172
109
  npm run dev -- --help
173
110
  ```
174
111
 
175
- ## 发布
176
-
177
- npm 发版通过 GitHub Actions Trusted Publishing 完成,不需要 `NPM_TOKEN`。npm 包设置里的 Trusted Publisher 需要匹配本仓库和 workflow 文件名 `publish.yml`。
178
-
179
- ```bash
180
- npm version patch --no-git-tag-version
181
- npm run prepare
182
- VERSION=$(node -p "require('./package.json').version")
183
- git commit -am "chore: release v$VERSION"
184
- git tag -a "v$VERSION" -m "v$VERSION" # 必须 annotated:--follow-tags 不推 lightweight tag
185
- git push --follow-tags
186
- ```
187
-
188
- 推送 `v*` tag 后,`.github/workflows/publish.yml` 会在 GitHub-hosted runner 上使用 OIDC 发布到 `https://registry.npmjs.org/`。也可以从 GitHub Actions 页面手动运行该 workflow。
189
-
190
- ## 版本更新
191
-
192
- 查看当前版本(自动与线上版本比对):
193
-
194
- ```bash
195
- gangtise --version
196
- ```
197
-
198
- 手动更新到最新版:
199
-
200
- ```bash
201
- npm update -g gangtise-openapi-cli
202
- ```
203
-
204
-
205
112
  ## 环境配置
206
113
 
207
114
  优先读取以下环境变量:
@@ -226,6 +133,7 @@ export GANGTISE_TIMEOUT_MS=30000 # 请求超时(默认 30s)
226
133
  本项目包含 Skill 定义(`gangtise-openapi/SKILL.md`),可让 AI agent 自动调用 `gangtise` CLI 完成投研数据查询。支持以下 AI 编程助手:
227
134
 
228
135
  - [Claude Code](https://claude.ai/claude-code) — `~/.claude/skills/`
136
+ - [Codex](https://github.com/openai/codex) — `~/.codex/skills/`
229
137
  - [OpenClaw](https://github.com/openclaw/openclaw) — `~/.openclaw/skills/`
230
138
  - [Hermes](https://github.com/nicepkg/hermes) — `~/.hermes/skills/`
231
139
 
@@ -256,6 +164,9 @@ gangtise-openapi/
256
164
  # Claude Code
257
165
  cp -r gangtise-openapi ~/.claude/skills/gangtise-openapi
258
166
 
167
+ # Codex
168
+ cp -r gangtise-openapi ~/.codex/skills/gangtise-openapi
169
+
259
170
  # OpenClaw
260
171
  cp -r gangtise-openapi ~/.openclaw/skills/gangtise-openapi
261
172
 
@@ -292,11 +203,13 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
292
203
  | | `research list` / `download` | 研报(含 Markdown 下载) |
293
204
  | | `foreign-report list` / `download` | 外资研报(含中文翻译下载) |
294
205
  | | `announcement list` / `download` | A股公告(含 Markdown 下载) |
295
- | | `announcement-hk list` / `download` | 港股公告(含下载) |
206
+ | | `announcement-hk list` / `download` | 港股公告(含 PDF/Markdown 下载) |
207
+ | | `announcement-us list` / `download` | 美股公告(含 PDF/Markdown 下载) |
296
208
  | | `foreign-opinion list` | 外资机构观点 |
297
209
  | | `independent-opinion list` / `download` | 外资独立分析师观点(含原文/翻译HTML下载) |
298
210
  | | `official-account list` / `download` | 产业公众号资讯(含 txt/HTML 下载) |
299
211
  | **Reference** | `securities-search` | GTS Code 搜索(按名称/代码/拼音匹配) |
212
+ | | `chiefs-search` | 首席分析师 ID 搜索(按姓名/机构/团队匹配) |
300
213
  | | `constant-category` | 常量分类列表(含各分类适用的接口与参数) |
301
214
  | | `constant-list` | 按分类导出常量值全量列表(行业/城市/公告分类/区域等) |
302
215
  | | `concept-search` | 题材 ID 搜索(名称/拼音/分组名匹配) |
@@ -309,6 +222,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
309
222
  | **Fundamental** | `income-statement` / `balance-sheet` / `cash-flow` | A股三大财务报表(累计) |
310
223
  | | `income-statement-quarterly` / `cash-flow-quarterly` | A股利润表/现金流量表(单季度) |
311
224
  | | `income-statement-hk` / `balance-sheet-hk` / `cash-flow-hk` | 港股三大财务报表(中国会计准则) |
225
+ | | `income-statement-us` / `balance-sheet-us` / `cash-flow-us` | 美股三大财务报表 |
312
226
  | | `main-business` | 主营构成(按地区/产品拆分) |
313
227
  | | `valuation-analysis` | 估值分析 |
314
228
  | | `earning-forecast` | 盈利预测(一致预期) |
@@ -316,6 +230,7 @@ cp -r gangtise-openapi ~/.hermes/skills/gangtise-openapi
316
230
  | **AI** | `knowledge-batch` | 知识库批量检索 |
317
231
  | | `knowledge-resource-download` | 知识资源下载 |
318
232
  | | `security-clue` | 个股线索 |
233
+ | | `stock-summary` | 个股看点(精炼投研总结,按代码或全市场;仅 A 股/港股) |
319
234
  | | `one-pager` | 一页通 |
320
235
  | | `investment-logic` | 投资逻辑 |
321
236
  | | `peer-comparison` | 同业对比 |
@@ -403,6 +318,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
403
318
  - `insight foreign-report list`
404
319
  - `insight announcement list`
405
320
  - `insight announcement-hk list`
321
+ - `insight announcement-us list`
406
322
  - `insight foreign-opinion list`
407
323
  - `insight independent-opinion list`
408
324
  - `insight official-account list`
@@ -411,6 +327,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
411
327
  - `vault record-list`
412
328
  - `vault my-conference-list`
413
329
  - `vault wechat-message-list`
330
+ - `vault wechat-chatroom-list`(特例:接口无 `total`,CLI 串行翻页)
414
331
  - `ai hot-topic`
415
332
 
416
333
  规则:
@@ -420,10 +337,11 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
420
337
  - `--from` 必须是非负整数,`--size` 必须是正整数;非法数字会在本地直接报 `ValidationError`,不会继续请求 API
421
338
  - 安全上限:自动翻页最多 1000 页,防止异常循环
422
339
  - 分页结果中 `total` 字段会被保留(json 格式输出 `{total, list}`),同时 stderr 输出 `Total: N, showing: M`
340
+ - `vault wechat-chatroom-list` 是特例:接口不返回 `total`,CLI 改为串行翻页——省略 `--size` 拉全量、传 `--size N` 取前 N 条,单页 50,无 `Total:` 提示
423
341
 
424
342
  ## 智能文件命名
425
343
 
426
- 下载命令(`summary download`、`research download`、`foreign-report download`、`announcement download`、`official-account download`、`vault drive-download`、`vault record-download`、`vault my-conference-download`)省略 `--output` 时,自动使用真实标题作为文件名:
344
+ 下载命令(`summary download`、`research download`、`foreign-report download`、`announcement download`、`announcement-hk download`、`announcement-us download`、`official-account download`、`vault drive-download`、`vault record-download`、`vault my-conference-download`)省略 `--output` 时,自动使用真实标题作为文件名:
427
345
 
428
346
  1. **缓存优先** — 如果之前执行过对应的 `list` 命令,标题已缓存在 `~/.config/gangtise/title-cache.json`,直接使用,无额外 API 调用
429
347
  2. **API 回查** — 缓存未命中时,自动查询最近 200 条记录匹配标题
@@ -474,6 +392,11 @@ gangtise insight roadshow list --institution C100000017
474
392
  # 港股公告
475
393
  gangtise insight announcement-hk list --security 01913.HK --rank-type 2 --size 20 --format json
476
394
  gangtise insight announcement-hk download --announcement-id ANN2026040200012345
395
+ gangtise insight announcement-hk download --announcement-id ANN2026040200012345 --file-type 2 # Markdown
396
+
397
+ # 美股公告(--security 用美股代码;分类用 reference constant-list --category usShareAnnouncementCategory)
398
+ gangtise insight announcement-us list --security TSLA.O --rank-type 2 --size 20 --format json
399
+ gangtise insight announcement-us download --announcement-id 49629029 --file-type 2 # Markdown
477
400
 
478
401
  # 外资机构观点
479
402
  gangtise insight foreign-opinion list --keyword "自动驾驶" --region us --rank-type 2 --format json
@@ -500,10 +423,15 @@ gangtise reference securities-search --keyword "600519" --category stock
500
423
  gangtise reference securities-search --keyword gzmt --top 5
501
424
  gangtise reference securities-search --keyword "银行" --category stock --category index
502
425
 
426
+ # 首席分析师 ID 搜索(按姓名/机构/团队;拿 chiefId 供 insight opinion list --chief 使用)
427
+ gangtise reference chiefs-search --keyword 东吴证券 --top 3 --format json
428
+ gangtise reference chiefs-search --keyword 芦哲 --format json
429
+
503
430
  # 常量查询:先看分类,再按分类导出全量常量值
504
431
  gangtise reference constant-category --format json
505
432
  gangtise reference constant-list --category citicIndustry --format json
506
433
  gangtise reference constant-list --category aShareAnnouncementCategory --format json # 树形,含 children
434
+ gangtise reference constant-list --category usShareAnnouncementCategory --format json # 美股公告分类(103980xxx 段)
507
435
 
508
436
  # 题材 ID 搜索(供 concept-info / concept-securities / theme-tracking 使用)
509
437
  gangtise reference concept-search --keyword 机器人 --top 3 --format json
@@ -578,6 +506,11 @@ gangtise fundamental balance-sheet-hk --security-code 09992.HK --fiscal-year 202
578
506
  gangtise fundamental cash-flow-hk --security-code 09992.HK --fiscal-year 2025 --period annual --field netOpCashFlows --field netInvCashFlows --field netFinCashFlows
579
507
  # 最新一期完整港股利润表
580
508
  gangtise fundamental income-statement-hk --security-code 09992.HK --format json
509
+
510
+ # 美股三大报表(--security-code 用美股代码;period 同港股但无 h2)
511
+ gangtise fundamental income-statement-us --security-code TSLA.O --period latest --format json
512
+ gangtise fundamental balance-sheet-us --security-code TSLA.O --fiscal-year 2025 --period annual --field totalAssets --field totalLiab --field totalEquity
513
+ gangtise fundamental cash-flow-us --security-code TSLA.O --fiscal-year 2024 --fiscal-year 2025 --period annual --field netOpCashFlows
581
514
  ```
582
515
 
583
516
  ### AI
@@ -588,6 +521,9 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
588
521
  gangtise ai knowledge-batch --query 新能源汽车 --resource-type 10 --resource-type 11 --top 10
589
522
  gangtise ai security-clue --start-time "2026-04-01 00:00:00" --end-time "2026-04-09 23:59:59" --query-mode byIndustry --gts-code 821035.SWI --source researchReport --source announcement
590
523
  gangtise ai one-pager --security-code 600519.SH
524
+ # 个股看点(精炼投研总结,仅 A 股/港股):传具体代码,或 aShares/hkStocks 拉全市场
525
+ gangtise ai stock-summary --security 600519.SH --security 00700.HK --format json
526
+ gangtise ai stock-summary --security hkStocks --format json
591
527
  gangtise ai investment-logic --security-code 600519.SH
592
528
  gangtise ai peer-comparison --security-code 600519.SH
593
529
  gangtise ai earnings-review --security-code 600519.SH --period 2025q3
@@ -754,3 +690,22 @@ CLI 会在本地校验常见数值参数,避免把明显非法的请求发到
754
690
  | `430007` | 行情查询超出限制(数据量过大,请缩短日期范围或减少 `--limit`) |
755
691
  | `410110` | 异步任务生成中(非终态,需继续轮询) |
756
692
  | `410111` | 异步任务生成失败(终态,不可重试) |
693
+
694
+ ---
695
+
696
+ ## 发布(维护者)
697
+
698
+ > 面向仓库维护者的发版流程,普通用户可跳过。
699
+
700
+ npm 发版通过 GitHub Actions Trusted Publishing 完成,不需要 `NPM_TOKEN`。npm 包设置里的 Trusted Publisher 需要匹配本仓库和 workflow 文件名 `publish.yml`。
701
+
702
+ ```bash
703
+ npm version patch --no-git-tag-version
704
+ npm run prepare
705
+ VERSION=$(node -p "require('./package.json').version")
706
+ git commit -am "chore: release v$VERSION"
707
+ git tag -a "v$VERSION" -m "v$VERSION" # 必须 annotated:--follow-tags 不推 lightweight tag
708
+ git push --follow-tags
709
+ ```
710
+
711
+ 推送 `v*` tag 后,`.github/workflows/publish.yml` 会在 GitHub-hosted runner 上使用 OIDC 发布到 `https://registry.npmjs.org/`。也可以从 GitHub Actions 页面手动运行该 workflow。
package/dist/src/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, Option } from "commander";
3
3
  import { checkAsyncContent, pollAsyncContent, POLL_MAX_ATTEMPTS } from "./core/asyncContent.js";
4
- import { readTokenCache } from "./core/auth.js";
4
+ import { readTokenCache, redactTokenCache } from "./core/auth.js";
5
5
  import { collectKeyValue, collectList, collectNumberList, maybeArray, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize, parseTimestamp13 } from "./core/args.js";
6
6
  import { buildIndicatorCrossSectionBody, buildIndicatorTimeSeriesBody, buildQuoteKlineBody, buildStockPoolStocksBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "./core/commandBodies.js";
7
7
  import { flattenCrossSection, flattenTimeSeries, unwrapIndicatorData } from "./core/indicatorMatrix.js";
@@ -9,7 +9,7 @@ import { callKlineWithSharding } from "./core/quoteSharding.js";
9
9
  import { loadConfig } from "./core/config.js";
10
10
  import { resolveTitle, saveDownloadResult } from "./core/download.js";
11
11
  import { ENDPOINTS } from "./core/endpoints.js";
12
- import { ApiError, ConfigError } from "./core/errors.js";
12
+ import { ApiError, ConfigError, ValidationError } from "./core/errors.js";
13
13
  import { normalizeRows } from "./core/normalize.js";
14
14
  import { parseOutputFormat } from "./core/output.js";
15
15
  import { printData } from "./core/printer.js";
@@ -100,14 +100,18 @@ program
100
100
  .command("auth")
101
101
  .description("Authentication commands")
102
102
  .addCommand(new Command("login")
103
+ .option("--show-token", "Show the raw access token (default: redacted)")
103
104
  .option("--format <format>", "Output format", "json")
104
- .action((options) => emit(options, (client) => client.login())))
105
+ .action((options) => emit(options, async (client) => {
106
+ const result = await client.login();
107
+ return options.showToken ? result : { authorization: "<redacted>", cache: redactTokenCache(result.cache) };
108
+ })))
105
109
  .addCommand(new Command("status")
106
110
  .option("--format <format>", "Output format", "json")
107
111
  .action(async (options) => {
108
112
  const config = loadConfig();
109
113
  const cache = await readTokenCache(config.tokenCachePath);
110
- await printData({ hasEnvToken: Boolean(config.token), hasCachedToken: Boolean(cache?.accessToken), cache }, parseOutputFormat(options.format));
114
+ await printData({ hasEnvToken: Boolean(config.token), hasCachedToken: Boolean(cache?.accessToken), cache: redactTokenCache(cache) }, parseOutputFormat(options.format));
111
115
  }));
112
116
  const lookup = new Command("lookup").description("Local lookup tables (IDs not covered by 'reference constant-list')");
113
117
  const addLookupList = (name, endpointKey, description) => {
@@ -130,6 +134,7 @@ const research = new Command("research");
130
134
  const foreignReport = new Command("foreign-report");
131
135
  const announcement = new Command("announcement");
132
136
  const announcementHk = new Command("announcement-hk");
137
+ const announcementUs = new Command("announcement-us");
133
138
  const foreignOpinion = new Command("foreign-opinion");
134
139
  const independentOpinion = new Command("independent-opinion");
135
140
  const officialAccount = new Command("official-account");
@@ -215,6 +220,12 @@ addTimeFilters(foreignReport.command("list").option("--search-type <number>", "S
215
220
  minReportPages: parseOptionalNumberOption(options.minPages, "--min-pages", { integer: true, min: 0 }), maxReportPages: parseOptionalNumberOption(options.maxPages, "--max-pages", { integer: true, min: 0 }),
216
221
  }), { endpointKey: "insight.foreign-report.list", idField: "reportId" }));
217
222
  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", default: "1" }, titleListEndpoint: "insight.foreign-report.list" });
223
+ // Contract: A-share announcement startTime/endTime go out as 13-digit epoch millis
224
+ // (parseTimestamp13), while HK/US announcement and every other insight list send the
225
+ // datetime string straight through. All three filter correctly — verified live against
226
+ // a narrow past window (each returns in-window rows). A-share's API also accepts the
227
+ // string form, but the 13-digit conversion is kept as the historical spec contract;
228
+ // don't "unify" it away without re-confirming the A-share announcement spec.
218
229
  addTimeFilters(announcement.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "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", {
219
230
  from: parseFrom(options.from), size: parseSize(options.size),
220
231
  startTime: parseTimestamp13(options.startTime, "--start-time"), endTime: parseTimestamp13(options.endTime, "--end-time"),
@@ -230,7 +241,16 @@ addTimeFilters(announcementHk.command("list").option("--search-type <number>", "
230
241
  keyword: options.keyword,
231
242
  securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
232
243
  }), { endpointKey: "insight.announcement-hk.list", idField: "announcementId" }));
233
- addDownloadCommand(announcementHk, { endpointKey: "insight.announcement-hk.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-hk", titleListEndpoint: "insight.announcement-hk.list" });
244
+ addDownloadCommand(announcementHk, { endpointKey: "insight.announcement-hk.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-hk", fileType: { description: "File type: 1=original 2=Markdown", default: "1" }, titleListEndpoint: "insight.announcement-hk.list" });
245
+ addTimeFilters(announcementUs.command("list").option("--search-type <number>", "Search type: 1=title 2=fulltext", "1").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "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", {
246
+ from: parseFrom(options.from), size: parseSize(options.size),
247
+ startTime: options.startTime, endTime: options.endTime,
248
+ searchType: parseNumberOption(options.searchType, "--search-type", { integer: true, min: 1 }),
249
+ rankType: parseNumberOption(options.rankType, "--rank-type", { integer: true, min: 1 }),
250
+ keyword: options.keyword,
251
+ securityList: maybeArray(options.security), categoryList: maybeArray(options.category),
252
+ }), { endpointKey: "insight.announcement-us.list", idField: "announcementId" }));
253
+ addDownloadCommand(announcementUs, { endpointKey: "insight.announcement-us.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement-us", fileType: { description: "File type: 1=original PDF 2=Markdown", default: "1" }, titleListEndpoint: "insight.announcement-us.list" });
234
254
  addTimeFilters(foreignOpinion.command("list").option("--rank-type <number>", "Rank type: 1=composite 2=time desc", "1").option("--security <code>", "Security code (e.g. UBER.N)", collectList, []).option("--region <code>", "Region code", collectList, []).option("--industry <id>", "Industry ID", collectList, []).option("--broker <id>", "Broker ID", collectList, []).option("--rating <name>", "Rating", collectList, []).option("--rating-change <name>", "Rating change", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>", "Output path")).action((options) => emit(options, (client) => client.call("insight.foreign-opinion.list", {
235
255
  from: parseFrom(options.from), size: parseSize(options.size),
236
256
  startTime: options.startTime, endTime: options.endTime,
@@ -269,6 +289,7 @@ insight.addCommand(research);
269
289
  insight.addCommand(foreignReport);
270
290
  insight.addCommand(announcement);
271
291
  insight.addCommand(announcementHk);
292
+ insight.addCommand(announcementUs);
272
293
  insight.addCommand(foreignOpinion);
273
294
  insight.addCommand(independentOpinion);
274
295
  insight.addCommand(officialAccount);
@@ -300,6 +321,9 @@ addFinancialReport("cash-flow-quarterly", "fundamental.cash-flow-quarterly", "Pe
300
321
  addFinancialReport("income-statement-hk", "fundamental.income-statement-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
301
322
  addFinancialReport("balance-sheet-hk", "fundamental.balance-sheet-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
302
323
  addFinancialReport("cash-flow-hk", "fundamental.cash-flow-hk", "Period: q1/h1/q3/h2/nsd/annual/latest");
324
+ addFinancialReport("income-statement-us", "fundamental.income-statement-us", "Period: q1/h1/q3/nsd/annual/latest");
325
+ addFinancialReport("balance-sheet-us", "fundamental.balance-sheet-us", "Period: q1/h1/q3/nsd/annual/latest");
326
+ addFinancialReport("cash-flow-us", "fundamental.cash-flow-us", "Period: q1/h1/q3/nsd/annual/latest");
303
327
  fundamental.command("main-business").requiredOption("--security-code <code>").option("--start-date <date>").option("--end-date <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) })));
304
328
  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>").option("--end-date <date>").option("--limit <number>").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) => {
305
329
  let data = await client.call("fundamental.valuation-analysis", { securityCode: options.securityCode, indicator: options.indicator, startDate: options.startDate, endDate: options.endDate, limit: parseOptionalNumberOption(options.limit, "--limit", { integer: true, min: 1 }), fieldList: maybeArray(options.field) });
@@ -328,7 +352,11 @@ fundamental.command("earning-forecast").requiredOption("--security-code <code>")
328
352
  }));
329
353
  program.addCommand(fundamental);
330
354
  const ai = new Command("ai").description("AI APIs");
331
- ai.command("knowledge-batch").requiredOption("--query <text>", "Query", collectList, []).option("--top <number>", "Top", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <ms>").option("--end-time <ms>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) })));
355
+ ai.command("knowledge-batch").option("--query <text>", "Query", collectList, []).option("--top <number>", "Top", "10").option("--resource-type <number>", "Resource type", collectNumberList, []).option("--knowledge-name <name>", "Knowledge name", collectList, []).option("--start-time <ms>").option("--end-time <ms>").option("--format <format>", "Output format", "json").option("--output <path>").action((options) => {
356
+ if (!options.query.length)
357
+ throw new ValidationError("--query is required: pass at least one --query");
358
+ return emit(options, (client) => client.call("ai.knowledge-batch", { queries: options.query, top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }), resourceTypes: options.resourceType.length ? options.resourceType : undefined, knowledgeNames: maybeArray(options.knowledgeName), startTime: parseOptionalNumberOption(options.startTime, "--start-time", { integer: true, min: 0 }), endTime: parseOptionalNumberOption(options.endTime, "--end-time", { integer: true, min: 0 }) }));
359
+ });
332
360
  ai.command("knowledge-resource-download").requiredOption("--resource-type <number>").requiredOption("--source-id <id>").option("--output <path>").action((options) => withClient(async (client) => {
333
361
  await runDownload(client, "ai.knowledge-resource.download", { resourceType: parseNumberOption(options.resourceType, "--resource-type", { integer: true, min: 0 }), sourceId: options.sourceId }, {
334
362
  output: options.output,
@@ -372,8 +400,8 @@ ai.command("hot-topic").option("--from <number>", "Starting offset", "0").option
372
400
  startDate: options.startDate,
373
401
  endDate: options.endDate,
374
402
  categoryList: options.category.length > 0 ? options.category : ALL_CATEGORIES,
375
- withRelatedSecurities: options.withRelatedSecurities === false ? undefined : true,
376
- withCloseReading: options.withCloseReading === false ? undefined : true,
403
+ withRelatedSecurities: options.withRelatedSecurities !== false,
404
+ withCloseReading: options.withCloseReading !== false,
377
405
  });
378
406
  }));
379
407
  ai.command("management-discuss-announcement").requiredOption("--report-date <date>", "Report date (yyyy-MM-dd, e.g. 2025-06-30)").requiredOption("--security-code <code>", "Security code (e.g. 000001.SZ)").addOption(new Option("--dimension <name>", "Discussion dimension: 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", {
@@ -406,6 +434,13 @@ ai.command("viewpoint-debate").requiredOption("--viewpoint <text>", "Viewpoint t
406
434
  }
407
435
  }));
408
436
  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)));
437
+ 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), or market keyword: aShares / hkStocks; max 6000", collectList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((options) => {
438
+ // Guard against an empty --security: omitting it would send securityList:undefined,
439
+ // which the backend may treat as all-market (3 credits/row × thousands of rows).
440
+ if (!options.security.length)
441
+ throw new ValidationError("--security is required: pass security code(s) or a market keyword (aShares / hkStocks)");
442
+ return emit(options, (client) => client.call("ai.stock-summary.list", { securityList: maybeArray(options.security) }));
443
+ });
409
444
  const reference = new Command("reference").description("Reference data APIs");
410
445
  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", {
411
446
  keyword: options.keyword,
@@ -423,6 +458,10 @@ reference.command("sector-search").option("--keyword <text>", "Search keyword (n
423
458
  top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
424
459
  })));
425
460
  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 })));
461
+ 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", {
462
+ keyword: options.keyword,
463
+ top: parseNumberOption(options.top, "--top", { integer: true, min: 1 }),
464
+ })));
426
465
  program.addCommand(reference);
427
466
  const vault = new Command("vault").description("Vault APIs");
428
467
  vault.command("drive-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Total rows to return; omit to fetch all").option("--start-time <datetime>").option("--end-time <datetime>").option("--keyword <text>").option("--file-type <number>", "File type", collectNumberList, []).option("--space-type <number>", "Space type", collectNumberList, []).option("--format <format>", "Output format", "table").option("--output <path>").action((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" }));
@@ -432,7 +471,7 @@ addDownloadCommand(vault, { endpointKey: "vault.record.download", name: "record-
432
471
  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>").option("--end-time <datetime>").option("--keyword <text>").option("--research-area <id>", "Research area ID", 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("--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) }), { endpointKey: "vault.my-conference.list", idField: "conferenceId" }));
433
472
  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" });
434
473
  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>").option("--end-time <datetime>").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", 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))));
435
- vault.command("wechat-chatroom-list").option("--from <number>", "Starting offset", "0").option("--size <number>", "Rows to return", "20").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))));
474
+ 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))));
436
475
  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", {})));
437
476
  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))));
438
477
  program.addCommand(vault);
@@ -42,12 +42,12 @@ export function collectNumberList(value, previous = []) {
42
42
  export function collectKeyValue(value, previous = {}) {
43
43
  const index = value.indexOf("=");
44
44
  if (index === -1) {
45
- throw new Error(`Invalid key=value pair: ${value}`);
45
+ throw new ValidationError(`Invalid key=value pair: ${value}`);
46
46
  }
47
47
  const key = value.slice(0, index).trim();
48
48
  const rawValue = value.slice(index + 1).trim();
49
49
  if (!key) {
50
- throw new Error(`Invalid key=value pair: ${value}`);
50
+ throw new ValidationError(`Invalid key=value pair: ${value}`);
51
51
  }
52
52
  return {
53
53
  ...previous,
@@ -1,10 +1,9 @@
1
1
  import { ApiError } from "./errors.js";
2
2
  import { printData } from "./printer.js";
3
+ // 14 attempts with exponential backoff (5s→30s cap) ≈ 316s total wait budget.
3
4
  export const POLL_MAX_ATTEMPTS = 14;
4
5
  const POLL_INITIAL_DELAY_MS = 5_000;
5
6
  const POLL_MAX_DELAY_MS = 30_000;
6
- /** Total wait time stays close to the previous 12*15s=180s budget. */
7
- export const POLL_DELAY_MS = POLL_INITIAL_DELAY_MS;
8
7
  function nextDelayMs(attempt) {
9
8
  // 5s, 8s, 13s, 20s, 30s, 30s, ...
10
9
  const grown = POLL_INITIAL_DELAY_MS * 1.6 ** (attempt - 1);
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { ConfigError } from "./errors.js";
@@ -14,9 +15,39 @@ export async function readTokenCache(filePath) {
14
15
  return null;
15
16
  }
16
17
  }
18
+ /**
19
+ * Return a display-safe copy of a token cache for `auth status`: any field whose
20
+ * name matches a credential pattern (token / key / secret / password / credential)
21
+ * is replaced with "<redacted>" so the raw bearer token — or any unknown credential
22
+ * field the cache file might carry (apiKey, privateKey, …) — is never printed; all
23
+ * other metadata (expiresAt, userName, productCode, …) is preserved.
24
+ */
25
+ export function redactTokenCache(cache) {
26
+ if (!cache)
27
+ return null;
28
+ const SENSITIVE = /token|secret|password|credential|key/i;
29
+ const out = {};
30
+ for (const [key, value] of Object.entries(cache)) {
31
+ out[key] = SENSITIVE.test(key) ? "<redacted>" : value;
32
+ }
33
+ return out;
34
+ }
17
35
  export async function writeTokenCache(filePath, cache) {
18
36
  await fs.mkdir(path.dirname(filePath), { recursive: true });
19
- await fs.writeFile(filePath, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
37
+ // Write to a fresh 0600 temp file then rename over the target. Writing the bearer
38
+ // token straight to token.json would (a) keep an existing file's lax perms — the
39
+ // `mode` option only applies on creation, so a follow-up chmod still leaves a brief
40
+ // world-readable window — and (b) risk a truncated file on crash. A temp file is
41
+ // 0600 from the first byte and rename is atomic, carrying the 0600 perms over.
42
+ const tmp = `${filePath}.tmp-${randomUUID()}`;
43
+ await fs.writeFile(tmp, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
44
+ try {
45
+ await fs.rename(tmp, filePath);
46
+ }
47
+ catch (error) {
48
+ await fs.unlink(tmp).catch(() => { });
49
+ throw error;
50
+ }
20
51
  }
21
52
  export function isTokenCacheValid(cache, bufferSeconds = 300) {
22
53
  if (!cache?.accessToken || !cache.expiresAt) {
@@ -17,11 +17,14 @@ export class GangtiseClient {
17
17
  config;
18
18
  refreshPromise = null;
19
19
  memoCache = null;
20
+ // After an injected env token (GANGTISE_TOKEN) is rejected and we self-heal via
21
+ // login, stop preferring that now-stale token so the retry uses the fresh one.
22
+ envTokenInvalidated = false;
20
23
  constructor(config) {
21
24
  this.config = config;
22
25
  }
23
26
  async getAuthorizationHeader(forceRefresh = false) {
24
- if (this.config.token && !forceRefresh) {
27
+ if (this.config.token && !this.envTokenInvalidated && !forceRefresh) {
25
28
  return normalizeToken(this.config.token);
26
29
  }
27
30
  if (!forceRefresh) {
@@ -69,6 +72,7 @@ export class GangtiseClient {
69
72
  && this.config.secretKey) {
70
73
  authState.retried = true;
71
74
  this.memoCache = null;
75
+ this.envTokenInvalidated = true;
72
76
  await this.getAuthorizationHeader(true);
73
77
  throw markRetryable(new ApiError(error.message, error.code, error.statusCode, error.details));
74
78
  }
@@ -175,19 +179,40 @@ export class GangtiseClient {
175
179
  }
176
180
  let unexpectedShape = false;
177
181
  let totalDrift = false;
182
+ // Fail-soft fan-out: a hard page failure (rate-limit 903301, no-perm, retries
183
+ // exhausted) must NOT discard the pages already fetched. Catch per page, record
184
+ // it, and stop starting new requests so we don't keep burning quota into a rate
185
+ // limit. Mirrors quoteSharding's partial-result tolerance — but firstPage already
186
+ // succeeded to get here, so unlike sharding there's no total-failure case.
187
+ const failedPages = [];
188
+ let firstError = null;
189
+ let aborted = false;
178
190
  const pages = await runWithConcurrency(pageRequests, PAGINATION_CONCURRENCY, async (req) => {
179
- const page = await this.requestJson(endpoint, {
180
- ...initialBody,
181
- from: req.from,
182
- size: req.size,
183
- });
184
- if (!this.isPaginatedListResponse(page)) {
185
- unexpectedShape = true;
191
+ if (aborted) {
192
+ failedPages.push(req);
193
+ return [];
194
+ }
195
+ try {
196
+ const page = await this.requestJson(endpoint, {
197
+ ...initialBody,
198
+ from: req.from,
199
+ size: req.size,
200
+ });
201
+ if (!this.isPaginatedListResponse(page)) {
202
+ unexpectedShape = true;
203
+ return [];
204
+ }
205
+ if (page.total !== total)
206
+ totalDrift = true;
207
+ return page.list;
208
+ }
209
+ catch (error) {
210
+ if (!firstError)
211
+ firstError = error;
212
+ aborted = true;
213
+ failedPages.push(req);
186
214
  return [];
187
215
  }
188
- if (page.total !== total)
189
- totalDrift = true;
190
- return page.list;
191
216
  });
192
217
  for (const list of pages) {
193
218
  if (list.length === 0)
@@ -206,11 +231,82 @@ export class GangtiseClient {
206
231
  if (truncatedByPageCap) {
207
232
  process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${collected.length} of ${total} rows. Narrow the query (e.g. a shorter date range) or pass --size to fetch a bounded subset.\n`);
208
233
  }
209
- return {
234
+ const out = {
210
235
  ...firstPage,
211
236
  total,
212
237
  list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
213
238
  };
239
+ if (failedPages.length > 0) {
240
+ out.partial = true;
241
+ out.failedPages = failedPages.map((p) => ({ from: p.from, size: p.size }));
242
+ const detail = firstError instanceof Error ? `: ${firstError.message}` : "";
243
+ process.stderr.write(`[gangtise] warning: ${failedPages.length}/${pageRequests.length} pages not fetched${detail}; results are partial — got ${collected.length}/${total} rows (see failedPages). A page hit a non-retryable error (e.g. rate limit); remaining pages were skipped.\n`);
244
+ }
245
+ return out;
246
+ }
247
+ /**
248
+ * Sequential pagination for endpoints that page by offset but return NO `total`
249
+ * and use a non-standard list key (e.g. wechat chatroom's `chatRoomList`). We
250
+ * can't fan out like requestPaginated (no total ⇒ unknown page count), so page
251
+ * serially until a short page (fewer rows than requested) signals the end.
252
+ * Returns `{ [listKey]: rows }` so normalize/printer treat it like any list.
253
+ */
254
+ async requestSequentialPaginated(endpoint, body) {
255
+ const initialBody = body && typeof body === 'object' ? { ...body } : {};
256
+ if ('from' in initialBody && (typeof initialBody.from !== 'number' || !Number.isFinite(initialBody.from) || initialBody.from < 0)) {
257
+ throw new ValidationError('Invalid from: expected a non-negative number');
258
+ }
259
+ if ('size' in initialBody && initialBody.size !== undefined && (typeof initialBody.size !== 'number' || !Number.isFinite(initialBody.size) || initialBody.size <= 0)) {
260
+ throw new ValidationError('Invalid size: expected a positive number');
261
+ }
262
+ const listKey = endpoint.pagination?.listKey ?? 'list';
263
+ const maxPageSize = endpoint.pagination?.maxPageSize ?? 50;
264
+ const startFrom = typeof initialBody.from === 'number' && Number.isFinite(initialBody.from) ? initialBody.from : 0;
265
+ const requestedSize = typeof initialBody.size === 'number' && Number.isFinite(initialBody.size) ? initialBody.size : undefined;
266
+ const extractList = (page) => {
267
+ if (!page || typeof page !== 'object')
268
+ return null;
269
+ const arr = page[listKey];
270
+ return Array.isArray(arr) ? arr : null;
271
+ };
272
+ const collected = [];
273
+ let firstPage = null;
274
+ let from = startFrom;
275
+ const MAX_PAGES = 1000;
276
+ let truncatedByPageCap = false;
277
+ for (let page = 0;; page++) {
278
+ const remaining = requestedSize === undefined ? maxPageSize : requestedSize - collected.length;
279
+ if (requestedSize !== undefined && remaining <= 0)
280
+ break;
281
+ const size = Math.min(maxPageSize, remaining);
282
+ const pageData = await this.requestJson(endpoint, { ...initialBody, from, size });
283
+ if (firstPage === null)
284
+ firstPage = pageData;
285
+ const list = extractList(pageData);
286
+ if (list === null) {
287
+ // First response isn't a paginated-list shape → return it untouched. But a
288
+ // LATER page losing shape must NOT discard the rows already collected (mirrors
289
+ // requestPaginated's fail-soft fan-out): stop, keep them, and warn loudly.
290
+ if (page === 0)
291
+ return firstPage;
292
+ process.stderr.write(`[gangtise] warning: a page response had unexpected shape; results are partial — ${collected.length} rows fetched.\n`);
293
+ break;
294
+ }
295
+ for (const item of list)
296
+ collected.push(item);
297
+ if (list.length < size)
298
+ break; // short page ⇒ no more rows
299
+ if (page + 1 >= MAX_PAGES) {
300
+ truncatedByPageCap = true;
301
+ break;
302
+ }
303
+ from += list.length;
304
+ }
305
+ if (truncatedByPageCap) {
306
+ process.stderr.write(`[gangtise] warning: hit the ${MAX_PAGES}-page safety cap; fetched ${collected.length} rows. Pass --size to fetch a bounded subset.\n`);
307
+ }
308
+ const rows = requestedSize === undefined ? collected : collected.slice(0, requestedSize);
309
+ return { [listKey]: rows };
214
310
  }
215
311
  async login() {
216
312
  const authorization = await this.getAuthorizationHeader();
@@ -379,7 +475,9 @@ export class GangtiseClient {
379
475
  return this.download(endpoint, query ?? {}, options);
380
476
  }
381
477
  if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
382
- return this.requestPaginated(endpoint, body);
478
+ return endpoint.pagination.sequential
479
+ ? this.requestSequentialPaginated(endpoint, body)
480
+ : this.requestPaginated(endpoint, body);
383
481
  }
384
482
  return this.requestJson(endpoint, body);
385
483
  }
@@ -2,6 +2,13 @@ import { extname } from "node:path";
2
2
  import { DownloadError } from "./errors.js";
3
3
  import { saveOutputIfNeeded } from "./output.js";
4
4
  import { lookupTitleCache, readTitleCache, TITLE_LOOKUP_SIZE } from "./titleCache.js";
5
+ /** Replace filesystem-unsafe characters (path separators, wildcards, and control
6
+ * characters / NUL) with `_` so a title or a server-supplied filename can't create
7
+ * stray subdirectories, escape the intended output path, or break fs.writeFile.
8
+ * Shared by title-based naming and the download fallback. */
9
+ function sanitizeFilename(name) {
10
+ return name.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "_");
11
+ }
5
12
  const MIME_EXT = {
6
13
  "application/pdf": ".pdf",
7
14
  "application/msword": ".doc",
@@ -38,7 +45,7 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
38
45
  const file = result;
39
46
  const serverExt = file.filename ? extname(file.filename) : extFromContentType(file.contentType);
40
47
  function buildFilename(rawTitle) {
41
- let title = rawTitle.replace(/[/\\:*?"<>|]/g, "_").trim();
48
+ let title = sanitizeFilename(rawTitle).trim();
42
49
  if (serverExt && !title.toLowerCase().endsWith(serverExt.toLowerCase())) {
43
50
  title += serverExt;
44
51
  }
@@ -76,7 +83,9 @@ export async function saveDownloadResult(result, fallbackName, output) {
76
83
  return;
77
84
  }
78
85
  if (file.data instanceof Uint8Array) {
79
- const outputPath = output ?? file.filename ?? (fallbackName + extFromContentType(file.contentType));
86
+ // Sanitize the server-provided filename so a Content-Disposition value with
87
+ // / or : can't write outside the intended path (same rule as buildFilename).
88
+ const outputPath = output ?? (file.filename ? sanitizeFilename(file.filename) : undefined) ?? (fallbackName + extFromContentType(file.contentType));
80
89
  await saveOutputIfNeeded(file.data, outputPath);
81
90
  process.stdout.write(`${outputPath}\n`);
82
91
  return;
@@ -138,6 +138,21 @@ export const ENDPOINTS = {
138
138
  kind: "download",
139
139
  description: "Download HK announcement file",
140
140
  },
141
+ "insight.announcement-us.list": {
142
+ key: "insight.announcement-us.list",
143
+ method: "POST",
144
+ path: "/application/open-insight/announcement-us/getList",
145
+ kind: "json",
146
+ description: "List US announcements",
147
+ pagination: { enabled: true, maxPageSize: 50 },
148
+ },
149
+ "insight.announcement-us.download": {
150
+ key: "insight.announcement-us.download",
151
+ method: "GET",
152
+ path: "/application/open-insight/announcement-us/download/file",
153
+ kind: "download",
154
+ description: "Download US announcement file",
155
+ },
141
156
  "insight.foreign-opinion.list": {
142
157
  key: "insight.foreign-opinion.list",
143
158
  method: "POST",
@@ -184,6 +199,13 @@ export const ENDPOINTS = {
184
199
  kind: "json",
185
200
  description: "Search GTS codes (securities)",
186
201
  },
202
+ "reference.chiefs-search": {
203
+ key: "reference.chiefs-search",
204
+ method: "POST",
205
+ path: "/application/open-reference/chiefs/search",
206
+ kind: "json",
207
+ description: "Search chief analyst IDs by name / institution / team",
208
+ },
187
209
  "reference.constant-category": {
188
210
  key: "reference.constant-category",
189
211
  method: "GET",
@@ -319,6 +341,27 @@ export const ENDPOINTS = {
319
341
  kind: "json",
320
342
  description: "Query HK cash flow statement (China GAAP)",
321
343
  },
344
+ "fundamental.income-statement-us": {
345
+ key: "fundamental.income-statement-us",
346
+ method: "POST",
347
+ path: "/application/open-fundamental/financial-report/income-statement/us",
348
+ kind: "json",
349
+ description: "Query US income statement",
350
+ },
351
+ "fundamental.balance-sheet-us": {
352
+ key: "fundamental.balance-sheet-us",
353
+ method: "POST",
354
+ path: "/application/open-fundamental/financial-report/balance-sheet/us",
355
+ kind: "json",
356
+ description: "Query US balance sheet",
357
+ },
358
+ "fundamental.cash-flow-us": {
359
+ key: "fundamental.cash-flow-us",
360
+ method: "POST",
361
+ path: "/application/open-fundamental/financial-report/cash-flow-statement/us",
362
+ kind: "json",
363
+ description: "Query US cash flow statement",
364
+ },
322
365
  "fundamental.main-business": {
323
366
  key: "fundamental.main-business",
324
367
  method: "POST",
@@ -348,6 +391,13 @@ export const ENDPOINTS = {
348
391
  description: "Query earning forecast (consensus estimates)",
349
392
  },
350
393
  // ─── ai ───
394
+ "ai.stock-summary.list": {
395
+ key: "ai.stock-summary.list",
396
+ method: "POST",
397
+ path: "/application/open-ai/stock-summary/getList",
398
+ kind: "json",
399
+ description: "Stock highlights (refined research summary per security)",
400
+ },
351
401
  "ai.knowledge-batch": {
352
402
  key: "ai.knowledge-batch",
353
403
  method: "POST",
@@ -515,6 +565,8 @@ export const ENDPOINTS = {
515
565
  path: "/application/open-vault/wechatgroupmsg/chatroomId",
516
566
  kind: "json",
517
567
  description: "List WeChat group chatroom IDs",
568
+ // No `total` in the response, list key is `chatRoomList`, server caps size at 50.
569
+ pagination: { enabled: true, maxPageSize: 50, sequential: true, listKey: "chatRoomList" },
518
570
  },
519
571
  "vault.stock-pool.list": {
520
572
  key: "vault.stock-pool.list",
@@ -46,23 +46,22 @@ function buildHeaders(names, codes, count) {
46
46
  return headers;
47
47
  }
48
48
  // Cross-section: one row per security, one column per indicator. The live
49
- // `values` is a flat [numIndicators * numSecurities][1] array in
50
- // indicator-major order, so indicator i on security j is values[i*numSec+j][0].
49
+ // `values` is a 2D [numIndicators][numSecurities] matrix in indicator-major
50
+ // order, so indicator i on security j is values[i][j].
51
51
  export function flattenCrossSection(data) {
52
52
  if (!data || typeof data !== "object")
53
53
  return data;
54
54
  const d = data;
55
- const securityCode = asStringArray(d.securityCode);
56
- const indicators = asStringArray(d.indicators);
55
+ const securityCode = asStringArray(d.securityCodeList);
56
+ const indicators = asStringArray(d.indicatorCodeList);
57
57
  if (!Array.isArray(d.values) || !securityCode || !indicators)
58
58
  return data;
59
- const securityName = asStringArray(d.securityName);
60
- const headers = buildHeaders(asStringArray(d.indicatorName), indicators, indicators.length);
61
- const numSec = securityCode.length;
59
+ const securityName = asStringArray(d.securityNameList);
60
+ const headers = buildHeaders(asStringArray(d.indicatorNameList), indicators, indicators.length);
62
61
  const list = securityCode.map((code, j) => {
63
62
  const row = { date: d.date, security: code, name: securityName?.[j] };
64
63
  for (let i = 0; i < indicators.length; i++) {
65
- row[headers[i]] = rowOf(d.values, i * numSec + j)?.[0];
64
+ row[headers[i]] = rowOf(d.values, i)?.[j];
66
65
  }
67
66
  return row;
68
67
  });
@@ -76,14 +75,14 @@ export function flattenTimeSeries(data) {
76
75
  return data;
77
76
  const d = data;
78
77
  const dates = asStringArray(d.dates);
79
- const securityCode = asStringArray(d.securityCode);
80
- const indicators = asStringArray(d.indicators);
78
+ const securityCode = asStringArray(d.securityCodeList);
79
+ const indicators = asStringArray(d.indicatorCodeList);
81
80
  if (!Array.isArray(d.values) || !dates || !securityCode || !indicators)
82
81
  return data;
83
82
  const seriesAreIndicators = securityCode.length <= 1;
84
83
  const headers = seriesAreIndicators
85
- ? buildHeaders(asStringArray(d.indicatorName), indicators, indicators.length)
86
- : buildHeaders(asStringArray(d.securityName), securityCode, securityCode.length);
84
+ ? buildHeaders(asStringArray(d.indicatorNameList), indicators, indicators.length)
85
+ : buildHeaders(asStringArray(d.securityNameList), securityCode, securityCode.length);
87
86
  const list = dates.map((date, k) => {
88
87
  const row = { date };
89
88
  for (let i = 0; i < headers.length; i++) {
@@ -42,14 +42,17 @@ function renderTable(rows) {
42
42
  return "(empty)";
43
43
  }
44
44
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
45
- const widths = columns.map((column) => {
46
- const cellWidths = rows.map((row) => formatScalar(row[column]).length);
47
- return Math.max(column.length, ...cellWidths);
48
- });
45
+ // Format every cell once (formatScalar may JSON.stringify objects), collapsing
46
+ // newlines so multi-line fields don't break alignment. Reuse the matrix for both
47
+ // width and rendering — and compute widths with reduce, NOT Math.max(...arr):
48
+ // spreading a per-row array overflows the call stack on large results (table is
49
+ // the default format, e.g. `quote day-kline --security all`).
50
+ const matrix = rows.map((row) => columns.map((column) => formatScalar(row[column]).replace(/[\r\n]+/g, " ")));
51
+ const widths = columns.map((column, c) => matrix.reduce((max, cells) => Math.max(max, cells[c].length), column.length));
49
52
  const renderLine = (values) => values.map((value, index) => value.padEnd(widths[index], " ")).join(" ");
50
53
  const header = renderLine(columns);
51
54
  const divider = renderLine(widths.map((width) => "-".repeat(width)));
52
- const body = rows.map((row) => renderLine(columns.map((column) => formatScalar(row[column]))));
55
+ const body = matrix.map((cells) => renderLine(cells));
53
56
  return [header, divider, ...body].join("\n");
54
57
  }
55
58
  function renderMarkdown(rows) {
@@ -59,7 +62,7 @@ function renderMarkdown(rows) {
59
62
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
60
63
  const header = `| ${columns.join(" | ")} |`;
61
64
  const divider = `| ${columns.map(() => "---").join(" | ")} |`;
62
- const body = rows.map((row) => `| ${columns.map((column) => formatScalar(row[column]).replaceAll("|", "\\|")).join(" | ")} |`);
65
+ const body = rows.map((row) => `| ${columns.map((column) => formatScalar(row[column]).replace(/[\r\n]+/g, " ").replaceAll("|", "\\|")).join(" | ")} |`);
63
66
  return [header, divider, ...body].join("\n");
64
67
  }
65
68
  function renderCsv(rows) {
@@ -96,7 +99,7 @@ export function renderOutput(value, format) {
96
99
  export async function streamOutputToFile(value, format, outputPath) {
97
100
  if (format !== "jsonl" && format !== "csv")
98
101
  return false;
99
- const list = pickListForStreaming(value);
102
+ const list = pickList(value);
100
103
  if (!list)
101
104
  return false;
102
105
  // Below this row count the join() approach is cheaper than per-row writes.
@@ -129,7 +132,9 @@ export async function streamOutputToFile(value, format, outputPath) {
129
132
  }
130
133
  return true;
131
134
  }
132
- function pickListForStreaming(value) {
135
+ /** Extract a row array from a value: the array itself, or its `.list` property,
136
+ * else null. Shared by streaming, printer's title-cache, and list detection. */
137
+ export function pickList(value) {
133
138
  if (Array.isArray(value))
134
139
  return value;
135
140
  if (value && typeof value === "object") {
@@ -141,9 +146,12 @@ function pickListForStreaming(value) {
141
146
  }
142
147
  function csvEscape(value) {
143
148
  let out = value;
144
- if (/^[=+\-@\t\r]/.test(out))
149
+ // Formula-injection guard, but don't mangle legitimate numbers: a leading
150
+ // -/+ only needs escaping when the cell isn't a finite number (e.g. "-1+cmd"),
151
+ // so values like "-3.5" stay numeric for Excel/pandas.
152
+ if (/^[=@\t\r]/.test(out) || (/^[+\-]/.test(out) && !Number.isFinite(Number(out))))
145
153
  out = "'" + out;
146
- if (/[",\n]/.test(out))
154
+ if (/[",\n\r]/.test(out))
147
155
  return `"${out.replaceAll("\"", "\"\"")}"`;
148
156
  return out;
149
157
  }
@@ -161,7 +169,7 @@ export async function saveOutputIfNeeded(content, outputPath) {
161
169
  return;
162
170
  }
163
171
  const { dirname } = await import("node:path");
164
- await (await import("node:fs/promises")).mkdir(dirname(outputPath), { recursive: true });
172
+ await fs.mkdir(dirname(outputPath), { recursive: true });
165
173
  if (typeof content === "string") {
166
174
  await fs.writeFile(outputPath, content, "utf8");
167
175
  return;
@@ -1,13 +1,9 @@
1
1
  import { normalizeRows } from "./normalize.js";
2
- import { renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
2
+ import { pickList, renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
3
3
  import { extractTitles, writeTitleCache } from "./titleCache.js";
4
4
  export async function printData(data, format, output, cache) {
5
5
  const normalized = normalizeRows(data);
6
- const items = Array.isArray(normalized)
7
- ? normalized
8
- : (normalized && typeof normalized === "object" && Array.isArray(normalized.list))
9
- ? normalized.list
10
- : null;
6
+ const items = pickList(normalized);
11
7
  if (cache && items) {
12
8
  const titles = extractTitles(items, cache);
13
9
  if (Object.keys(titles).length > 0) {
@@ -93,8 +93,11 @@ export async function callKlineWithSharding(client, endpointKey, body, config) {
93
93
  header = rec;
94
94
  if (!fieldList && Array.isArray(rec.fieldList))
95
95
  fieldList = rec.fieldList;
96
+ // Append one-by-one rather than push(...list): a future higher row cap could
97
+ // make a single shard's list large enough to overflow the stack via spread.
96
98
  if (Array.isArray(rec.list))
97
- merged.push(...rec.list);
99
+ for (const item of rec.list)
100
+ merged.push(item);
98
101
  }
99
102
  // Every shard failed → surface the error loudly (non-zero exit) rather than
100
103
  // masking a total outage as an empty success.
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.19.0";
2
+ export const CLI_VERSION = "0.21.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,7 @@
26
26
  "build": "tsc -p tsconfig.json",
27
27
  "dev": "tsx src/cli.ts",
28
28
  "test": "vitest run",
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
29
30
  "prepare": "node scripts/prepare.cjs && npm run build"
30
31
  },
31
32
  "engines": {
@@ -39,6 +40,6 @@
39
40
  "@types/node": "^24.3.0",
40
41
  "tsx": "^4.20.5",
41
42
  "typescript": "^5.9.2",
42
- "vitest": "^3.2.4"
43
+ "vitest": "^3.2.6"
43
44
  }
44
45
  }