@bty/customer-service-cli 0.4.8 → 0.5.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/CHANGELOG.md ADDED
@@ -0,0 +1,286 @@
1
+ # Changelog
2
+
3
+ ## 0.5.0 (2026-05-28)
4
+
5
+ **Breaking change**
6
+
7
+ - `issue update-owner` 移除 `--owner-phone` 参数。该参数最初设计为"传手机号、由服务端解析为 user_id",但后端从未真正接入该字段,CLI 端的传入会被静默忽略(Issue #49)。此次硬删,避免继续误导调用者。
8
+ - 现在 `--owner <user_id_or_name>` 必填,支持 `user_id`(≤7 位纯数字)或工程师花名(CLI 通过 cs-admin `/api/account-roles` 解析,5min 内存 cache,同名歧义会报错列 candidates)。
9
+ - 升级路径:直接传花名(`--owner xinghe`)或 `user_id`(`--owner 1862`)。
10
+ - 受影响脚本:任何使用 `--owner-phone` 的脚本会被 commander 拒收并非 0 退出,需替换为 `--owner`。
11
+
12
+ **新增:批量改派/更新/创建 issue**
13
+
14
+ - `issue update-owner` 新增批量模式:`--ids 1,2,3 / --ids-file path` + `--owner <user_id|花名> [--dry-run] [--yes]`。
15
+ - 单次调用 cs-admin BFF `POST /api/workbench/issues/batch-owner`,原子改派 + 自动 Feishu/DingTalk 通知。
16
+ - 单批最多 200 条;N≥20 + TTY 二次确认,非 TTY 必须 `--yes`。
17
+ - `issue update` 新增批量模式:`--ids ... / --ids-file ...` + `--data '{"status":"closed"}' [--dry-run] [--concurrency 5] [--yes]`。
18
+ - 串行 PATCH /v1/issues/{id} 模式(fallback),末尾统一报告 `{success_count, failed_count, skipped_count, success_ids, failed[], skipped[]}`。
19
+ - `issue create` 新增批量模式:`--file <path>` 或 `--stdin` 读 JSON(顶层 `version` + `workspace_id` + `issues[]`)。
20
+ - 每条 issue 必填 `title` / `priority`(critical|high|medium|low)/ `expected`,且 `conversation_id` 与 `user_name` 二选一。
21
+ - 仅给 `user_name` 时,CLI 自动调 `GET /v1/chat/conversations?user_name=&workspace_id=` 反查 conversation_id(5min cache)。
22
+ - CLI 把可选 `description` + 必填 `expected` 拼成 `content`:`{description}\n\n## Expected\n{expected}`。
23
+ - `--dry-run` 会真跑反查 + content 拼装,但不调 POST /v1/issues。
24
+ - Exit code 协议:0 全成功 / 1 全失败 / 2 部分失败 / 130 SIGINT。
25
+ - 双后端 / 单 token:servhub Bearer JWT 通过 cs-admin BFF 鉴权,CLI 只需一套登录态。
26
+ - Pipe-first:`issue list --json | jq ... | xargs cs-cli issue update-owner --ids ...` 可串。
27
+
28
+ ## 0.4.8 (2026-05-27)
29
+
30
+ - 补发测试集(TestSet)回归测试全套命令。相关代码随测试集二期 M3 经 `feature/0522-test-dataset-phase-2` 合入 release,但 `0.4.7` 的 npm 发布产物实际**未包含**这套代码(publish 时工作副本尚未合入该分支,只带了 transfer-human),导致 `cs-cli testset` 在线上不可用。本次从含测试集代码的 HEAD 重新构建发布。`cs-cli testset` 主语下提供:
31
+ - `testset list / show / update / delete / copy`:测试集本体的列表 / 详情 / 更新 / 删除 / 复制。
32
+ - `testset get-eval-prompt / set-eval-prompt`:读取 / 设置测试集评价提示词(`set-eval-prompt --prompt @file` 支持读纯文本 Markdown,不做 JSON.parse)。
33
+ - `testset case list / show / create / update / delete`:测试集内用例的增删改查(`--data <json|@file>`)。
34
+ - `testset batch list / show / status`:执行批次的列表 / 详情 / 实时状态(含 `is_terminal`)。
35
+ - `testset run --testset <id> --agent <id>`:触发测试集回归执行,默认异步;`--wait` 切同步轮询终态(3s 间隔,`--timeout` 控上限,单位秒,超时仍 exit 0 / ADR-002)。
36
+ - `testset run-case --batch <id> --case <id>`:单条用例重跑。
37
+ - `testset export --testset <id> --batch <id> --output <path>`:批次结果导出为 xlsx 落盘(父目录自动 mkdir)。
38
+ - 对接 customer-servhub-api 的 `/v1/test_sets/*` 系列接口(`/cases`、`/execution_batches`、`/execute`、`/{id}/evaluate-prompt`、`/{id}/duplicate`、`/{id}/export`),走 CLI 标准 Bearer 鉴权与全局 `--table` 输出。
39
+
40
+ ## 0.4.7 (2026-05-26)
41
+
42
+ - `conversation` 新增 `transfer-human` 子命令:`conversation transfer-human --agent <customer_agent_config_id> --start <ISO> --end <ISO> [--page N]`,对接 `GET /v1/chat/conversations/transfer-human`,用于查询指定 Agent 在给定时间范围内的转人工会话及对应转人工消息记录。
43
+ - 查询口径按 `MANUAL_TAKEOVER_REQUIRED` 事件时间过滤会话,不再按会话 `updated_at` 推断;CLI 在本地校验开始/结束时间格式、结束时间不能早于开始时间、查询窗口最多 7 天。
44
+ - 返回结果同时包含:
45
+ - `takeover_event_record`:命中的转人工事件列表
46
+ - `transfer_to_human_records`:同时间范围内 `transfer_to_human=true` 的消息记录,新增 `trace_id` 字段(来源 `reply_message_statistics.flow_trace_id`)
47
+ - `transfer-human` 返回的会话字段收敛为转人工场景所需的最小集合,避免沿用通用会话列表的冗余字段;分页大小由服务端固定为 `100`,CLI 仅暴露 `--page`。
48
+
49
+ ## 0.4.5 (2026-05-25)
50
+
51
+ - `product` 新增 `add` 子命令:直接传入结构化商品数据(JSON 数组)异步学习。`--agent <id> --data <json|@file> [--source <type>] [--no-identify]`,对接 `POST /v1/knowledge/products/upload` 的 `product_items`(与 `product learn` 的 `product_urls` 是同一接口的两种载荷)。`--data` 接受 JSON 字符串或 `@文件路径`;单个对象自动包成数组,传 JSON 基本类型(数字/字符串等)直接报错;`--source` 默认 `manual`,`--no-identify` 关闭自动识别。
52
+ - `faq` 新增 `content` 子命令:列出指定 FAQ 文件内的答案组(含 `answer_group_id` / 问题 / 答案)。`--agent <id> --file <文件名> [--keyword <词>] [--page-size <n>] [--next-group-id <id>]`,走 `GET /mebsuta/api/v1/dataset/{knowledge_id}/faq_contents`。补齐 `faq update` / `faq delete` 所需 `--group-id` 的可发现性——此前 `faq list` 只列文件、无法查到文件内各答案组的 `answer_group_id`。
53
+ - 修正 `product-sync.test.ts` 一处自 0.4.4 起就遗漏的断言(`sync-taobao` 默认发送 `sync_type=full`)。
54
+
55
+ ## 0.4.4 (2026-05-25)
56
+
57
+ - `product sync-taobao` 新增 `--sync-type <full|incremental>`,对接 `POST /v1/authorized-shop/sync-taobao-products` 的 `sync_type` 参数;CLI 默认仍发送 `full`。
58
+ - 文档同步更新:`README.md` 与命令描述改为“淘宝店铺商品同步”,补充 `--sync-type` 用法说明。
59
+
60
+ ## 0.4.3 (2026-05-22)
61
+
62
+ - `product` 新增两个淘宝商品同步命令:
63
+ - `product sync-taobao --agent <id> [--skip-hash-check]`:对接 `POST /v1/authorized-shop/sync-taobao-products`,按 `customer_agent_config_id` 触发店铺商品全量同步,可选跳过哈希校验强制重跑。
64
+ - `product sync-taobao-item --agent <id> --product-id <id>`:对接 `POST /v1/authorized-shop/sync-single-product`,按 `customer_agent_config_id + product_id` 触发单个淘宝商品同步。
65
+ - `product sync-taobao` / `product sync-taobao-item` 仅支持已开通“高级工具”的店铺 Agent。
66
+
67
+ ## 0.4.2 (2026-05-21)
68
+
69
+ - `issue` 新增 `update-owner <issue_id>` 子命令:更新工单负责人。`--owner <user_id>` 与 `--owner-phone <phone>` 二选一(互斥),底层调用 `PATCH /v1/issues/{issue_id}`,body 为 `{ owner }` 或 `{ owner_phone }`,后者由服务端解析为 user_id。CLI 侧仅做参数校验与透传,依赖 customer-servhub-api 后续开放 `owner` / `owner_phone` 字段后方可生效。
70
+
71
+ ## 0.4.1 (2026-05-19)
72
+
73
+ - 新增 `change-consumer` 命令组:商品变更事件消费链路(pull 模式,默认 `consumer_key=ops_agent_assistant`)。标准工作流 `sub create 创建订阅 → delivery list 轮询 → delivery claim 认领 → 处理 → delivery complete 回写`:
74
+ - `change-consumer sub create`:创建订阅,最简只需 `--workspace-id` + `--events`(默认 `product.updated,product.deleted`);`--agent-config` 缺省表示该 workspace 全部商品事件;`--disabled` 创建为停用;`--data <json|@文件>` 高级覆盖。
75
+ - `change-consumer sub list` / `sub update <subscription_id>`:查询 / 更新订阅(`enabled` / `events` / `filters` / `consumer_name`)。
76
+ - `change-consumer delivery list`:查询待消费项,支持 `--statuses` 过滤与 `--cursor-created-at` / `--cursor-delivery-id` 增量游标。
77
+ - `change-consumer delivery claim <delivery_id> --claimed-by <id>`:认领(`--lease-seconds` 默认 300,已被他人认领且未过期会失败)。
78
+ - `change-consumer delivery complete <delivery_id> --status <completed|ignored|failed>`:回写结果,`--result-action` / `--result-note` / `--last-error`,或 `--data` 高级覆盖。
79
+ - 对接 customer-servhub-api 的 `/v1/change-consumers/*` 接口,走 CLI 标准 Bearer 鉴权与全局 `--table` 输出。
80
+
81
+ ## 0.4.0 (2026-05-17)
82
+
83
+ > 版本号说明:本次含用户可见破坏性变更(移除 langfuse 直连配置项),沿用既有规律(0.2.0 / 0.3.0 的 Breaking 均走 minor bump)定为 0.4.0。`package.json` 的 `version` 由发布流程(`pnpm run release`)统一 bump,本条目以 0.4.0 记录变更内容。
84
+
85
+ - `debug trace <trace_id>` 新增 `--env <dev|prod>` 选项(默认 `prod`):用于指定 langfuse 环境。行为变更——不再由 CLI 直连 langfuse REST API,改为请求 customer-servhub-api 的 `GET /v1/debug/langfuse/trace/{trace_id}?env=<env>` 接口(走 CLI 标准 Bearer 鉴权),由服务端按 env 选择 langfuse 凭据并代理拉取 trace。`--env` 仅接受 `dev` / `prod`,传其它值命令直接报错并以非 0 退出,不发请求。
86
+ - **BREAKING**: 移除 `config set` 的 `--langfuse-host` / `--langfuse-public-key` / `--langfuse-secret-key` 三个选项,及 `CLIConfig` 的 `langfuseHost` / `langfusePublicKey` / `langfuseSecretKey` 字段(随 langfuse 直连一并下线)。迁移:`~/.cs-cli/config.json` 中 `langfuseHost` / `langfusePublicKey` / `langfuseSecretKey` 字段可直接删除;langfuse 连接信息(含 dev/prod 双环境凭据)现由 customer-servhub-api 服务端配置,CLI 侧无需也无法再配置;环境变量 `LANGFUSE_BASE_URL` / `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` 在 CLI 侧不再被读取。
87
+ - 清理 langfuse 直连死代码:`helpers.ts` 的 `getLangfuseConfig` 及 `DEFAULT_LANGFUSE_HOST` / `DEFAULT_LANGFUSE_PUBLIC_KEY` / `DEFAULT_LANGFUSE_SECRET_KEY` 内置默认常量全部移除(不再向 CLI 二进制内嵌 langfuse 凭据)。
88
+
89
+ ## 0.3.3 (2026-04-23)
90
+
91
+ - `workspace` 新增 `points-consumes-daily` 子命令:按天查询当前工作空间的积分消耗(`POST /v1/workspace/points_consumes/daily_deduction`,body `start_time` / `end_time`)。支持 `--start <YYYY-MM-DD>` / `--end <YYYY-MM-DD>`,两者均可选,默认最近 30 天(end=今天,start=今天-29d)。返回 `consume_date` / `total_consume_points` 列表,沿用全局 `--table` 输出。`workspace-id` 由 `request.ts` 统一从 env lock / `--workspace` / 本地 / 全局配置解析,无需在该命令里显式传入。
92
+
93
+ ## 0.3.2 (2026-04-22)
94
+
95
+ - **修复 0.3.1 引入的回归**:全局 `--timeout <ms>` 与子命令 `--timeout <seconds>` 同名,commander v14 会把子命令位置上的 `--timeout 60` 上浮给 program,preAction 把 `60` 当毫秒写进 runtime store,所有请求在 ~60ms 就被 abort(症状:`cs-cli debug ask --text ... --timeout 60` 在 ~0.226s 返回 `aborted due to timeout`)。
96
+ - 重命名全局标志 `--timeout <ms>` → `--request-timeout <ms>`,消除命名冲突:
97
+ - `cs-cli debug ask --timeout 60` 现在 commander 直接把 `60` 分给子命令(轮询窗口,秒),HTTP 超时用 60000ms 硬编码 fallback。
98
+ - 需要单独抬高 HTTP 单次超时:`cs-cli --request-timeout 120000 debug ask --timeout 60`(HTTP 120s + 轮询 60s 独立可控)。
99
+ - 子命令 `debug ask` / `debug chat` / `debug replay` 各自的 `--timeout <seconds>` 不变。
100
+
101
+ ## 0.3.1 (2026-04-21)
102
+
103
+ - 修复全局 `--timeout <ms>` 死代码:此前 `bin.ts` 声明后没有代码读取 `program.opts().timeout`,所有请求都落到 `request.ts` 里 20000ms 硬编码 fallback,`AbortSignal.timeout` 在 ~20.24s 稳定 abort,`debug chat` 长耗时 Agent 回复表现为卡死。现在 `--timeout` 通过 `setRuntimeRequestTimeoutMs` 贯穿到所有 `createRequest()` 调用。
104
+ - 默认超时由 20s 提到 60s:`--timeout` 默认值 `20000` → `60000`,`request.ts` 的硬编码底座同步提升。
105
+ - 优先级链:`options.timeout` > `createRequest(globalTimeout)` 参数 > 全局 `--timeout` 写入的 runtime store > 60000。
106
+ - 不改动 `debug chat` / `debug reproduce` 各自的 `--timeout <seconds>`(轮询窗口,语义独立)。
107
+
108
+ ## 0.3.0 (2026-04-17)
109
+
110
+ - **BREAKING**: 合并 `authApiUrl` 与 `aiApiUrl` 配置字段。`CLIConfig.authApiUrl` 已移除;auth 与 ai 请求都走 `aiApiUrl`(默认仍为 `https://ai-api.betteryeah.com`)。迁移:`~/.cs-cli/config.json` 中 `authApiUrl` 字段可直接删除;若此前 `authApiUrl` 与 `aiApiUrl` 指向不同地址,请手动把目标值写入 `aiApiUrl`。
111
+ - **BREAKING**: 移除 `config set --auth-api <url>` 选项。迁移:`cs-cli config set --ai-api <url>`,或通过环境变量 `CS_AI_API_URL=<url>` 覆盖。
112
+ - 三个 API URL 新增环境变量作为**第一优先级**覆盖源:`CS_CS_API_URL` / `CS_AI_API_URL` / `CS_CUSTOMER_AGENT_URL`。优先级为 `env > config.json > 默认值`。空字符串视为未设置。
113
+
114
+ ## 0.2.1 (2026-04-16)
115
+
116
+ - `product update-sku` 支持 `--sku-id <id>`,与 `--sku` 至少二选一;两者同时提供时优先使用 `sku_id`。SKU 名称在商品复杂字符场景下易写错,`sku_id` 作为全局唯一 ID 更稳定,推荐优先使用。
117
+ - `updateSku` 客户端函数新增可选 `skuId`,提供时 body 只携带 `sku_id`;两者都缺时 throw。`--sku` 由 `requiredOption` 改为 `option`,向后兼容仅传 `--sku` 的旧调用。
118
+ - 补 3 条单测覆盖 skuId-only / both-prefers-skuId / neither-throws;README 表格与示例同步。
119
+
120
+ ## 0.2.0 (2026-04-15)
121
+
122
+ - **Breaking**: env lock 环境变量加 `CS_` 前缀,避免与通用命名冲突(HTTP `Authorization` 头、其它工具)。旧名 `AUTHORIZATION` / `WORKSPACE_ID` / `AGENT_ID` 不再识别,sandbox / CI 需改用 `CS_AUTH` / `CS_WORKSPACE_ID` / `CS_AGENT_ID`。行为不变:env 有值则锁定凭据/上下文,任何 `--workspace` / `--agent` / `auth login` 等 override 硬失败。
123
+ - 首次在 README 文档化 env lock 约定(工作空间优先级加 priority 0,新增"环境变量锁定(sandbox / CI)"章节)。
124
+
125
+ ## 0.1.27 (2026-04-14)
126
+
127
+ - `faq` 新增 `update` / `delete` 子命令:`faq update` 通过 `answer_group_id` 更新已有 FAQ 组(支持 `--questions` / `--answers` / `--delete-chunks` 任选组合),`faq delete` 删除指定 FAQ 组。
128
+ - 新增 `knowledge` 命令组:`knowledge list` 列出扩展知识库(`suffix_type=_common`);`knowledge content list|add|update|delete` 对段落做 CRUD,统一走 `/mebsuta/api/v1/dataset/{id}/contents/perform-operations`。`content add` / `content update` 的 `--content` 支持 `@file` 前缀从文件读取纯文本。
129
+ - `knowledge content update` 未显式指定 `--enable` / `--disable` 时,自动从段落当前快照读取 enable 状态透传(read-modify-write),避免后端 `enable` 必填校验失败,也不会意外切换启停状态。
130
+ - `product` 新增 `learn` 子命令:提交商品 URL 进入异步学习(`/v1/knowledge/products/upload`),支持 `--url` variadic、`--source`、`--no-identify`。
131
+
132
+ ## 0.1.26 (2026-04-11)
133
+
134
+ - `debug record` 新增 `--deep` 深度解析选项:额外调用 ai-api `/v1/flow/execute_log/{flow_id}/{task_id}`,再跟随返回的 `log_url` 拉取完整执行日志(`actionOutputs` 节点级输入输出),用于排查 flow 内部行为。默认关闭以保持向后兼容。配套 `--flow-workspace <id>` 覆盖 flow 所在 workspace(默认内置固定值 `531c14d1...`)。
135
+
136
+ ## 0.1.25 (2026-04-08)
137
+
138
+ - 重构重放消息 content 构建:直接使用原始 `context` 结构,保留所有字段(`url`、`image`、`is_entry_product` 等)
139
+
140
+ ## 0.1.24 (2026-04-08)
141
+
142
+ - 修复重放消息缺少 `url` 字段:CARD 等类型的 `context.url` 现在会正确带入 `content.url`
143
+
144
+ ## 0.1.23 (2026-04-07)
145
+
146
+ - 修复重放消息图片字段:`content.image_url` 改为 `content.image`,与服务端接口一致
147
+
148
+ ## 0.1.22 (2026-04-07)
149
+
150
+ - 修复 `debug reproduce` 重放时图片消息丢失:兼容 `context.image` 字段,正确使用原始 `context_type`(不再硬编码 `TEXT`)
151
+
152
+ ## 0.1.21 (2026-04-07)
153
+
154
+ - `debug reproduce` 返回结果新增顶层 `trace_id` 字段,复现完成后自动获取 Langfuse Trace ID
155
+
156
+ ## 0.1.20 (2026-04-07)
157
+
158
+ - 修复 `debug reproduce` 复现旧对话时消息被服务端过滤的问题:将时间戳重置到当前时间,保持消息间相对顺序
159
+
160
+ ## 0.1.19 (2026-04-07)
161
+
162
+ - `conversation list` 新增 `--equipment-id` 参数,支持按设备 ID 筛选会话
163
+ - `conversation list` 的 `--agent` 参数从必填改为可选
164
+
165
+ ## 0.1.17 (2026-04-03)
166
+
167
+ - 修复 `ops-record` API 地址:从 `customer-agent.bantouyan.com` 迁移到 `customer-servhub-api.betteryeah.com`
168
+ - 路径从 `/api/agent_operations_records` 改为 `/v1/agent_operations_records`
169
+ - 认证方式从 Cookie auth 改为 Bearer token(与其他 customer-servhub-api 端点一致)
170
+ - 修复 `ops-record list/create` 的 `--workspace` 选项与全局 `--workspace` 冲突,改为 `--workspace-id`
171
+ - `config set` 新增 `--langfuse-host`、`--langfuse-public-key`、`--langfuse-secret-key` 选项
172
+
173
+ ## 0.1.16 (2026-04-01)
174
+
175
+ - 新增 `debug reproduce <record_id>` 命令,根据一条回复记录自动复现 Agent 回复
176
+ - 通过 recordId 获取关联会话(`/v1/chat/records/{recordId}/relations`)和 Agent 配置
177
+ - 拉取完整会话记录,截取目标 record 之前的所有上下文
178
+ - 使用原始 `user_name` 创建新 debug 会话并发送
179
+ - `--agent <config_id>` 可指定另一个 Agent 复现同一段上下文(A/B 对比)
180
+ - `--dry-run` 仅输出截断后的上下文 msg_list,不发送
181
+ - `--timeout` / `--poll-interval` 控制等待时间
182
+ - `pollAgentReply` 新增 `category` 过滤参数,reproduce 场景下只等 `agent_response`,避免历史 report 记录干扰
183
+ - 新增 `getRecordRelations`、`truncateBeforeRecord`、`recordsToMsgList` 函数及测试(6 个用例)
184
+ - 更新 README、SKILL.md、workflows.md 文档
185
+
186
+ ## 0.1.15 (2026-04-01)
187
+
188
+ - 新增 `debug trace <trace_id>` 命令,根据 Langfuse Trace ID 获取完整 Trace 详情
189
+ - 直接调用 Langfuse REST API(`GET /api/public/traces/:traceId`),使用 Basic Auth 认证
190
+ - Trace ID 从 `debug record` 返回的 `trace_id` 字段获取
191
+ - `debug record` 返回值新增 `trace_id` 字段,从 `flow_info` 中自动提取
192
+ - 新增 Langfuse 连接配置,支持环境变量(`LANGFUSE_BASE_URL` / `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY`)和配置文件两种覆盖方式,内置默认连接信息
193
+ - 补充 `getLangfuseTrace` 测试(2 个用例)
194
+ - 更新 README 文档
195
+
196
+ ## 0.1.14 (2026-03-31)
197
+
198
+ - 新增 `ops-record` 命令(运维操作记录管理),支持 list/get/create/update/delete 五个子命令
199
+ - 运维工程师可上传和查询对 Agent 的操作记录
200
+ - 创建时 `operator_id` 和 `operator_name` 自动从当前登录用户获取
201
+ - 新增 `operations-record-api` 测试(8 个用例)
202
+ - `package.json` 新增 `publish` 脚本
203
+ - CLAUDE.md / AGENTS.md 明确禁止使用 `npm publish`
204
+
205
+ ## 0.1.13 (2026-03-28)
206
+
207
+ - 修复发布问题:改用 pnpm publish 重新发布
208
+
209
+ ## 0.1.12 (2026-03-27)
210
+
211
+ - 新增 `conversation context-search` 命令,支持按上下文内容模糊搜索对话记录
212
+ - 返回匹配消息详情,包含 `conversation_id`、`record_id`、`context` 等字段
213
+ - 自动使用当前工作空间作为查询范围
214
+ - 为 `context-search` 增加本地时间窗口校验
215
+ - 当 `--start` 与 `--end` 同时传入且跨度超过 3 天时,CLI 直接报错,不再等后端返回 400
216
+ - 补充 `conversation-api` 测试与 README 示例
217
+ - 覆盖查询参数透传、可选时间参数省略和超范围校验
218
+ - README 明确标注查询窗口最多 3 天
219
+
220
+ - `issue create` 新增可选参数:`--conversation-id`、`--record-id`、`--workspace-id`、`--source-type`、`--source-data`
221
+ - 支持关联会话和消息记录,指定来源类型和来源数据
222
+ - API 层新增 `CreateIssueParams` 类型定义
223
+
224
+ ## 0.1.10 (2026-03-27)
225
+
226
+ - 新增自动检查更新功能:命令执行后非阻塞检查 npm registry 最新版本
227
+ - 缓存机制:4 小时内不重复请求(`~/.cs-cli/update-check.json`)
228
+ - 提示输出到 stderr,不影响 JSON stdout
229
+ - 网络异常时静默忽略,不影响正常使用
230
+ - 设置 `CS_CLI_NO_UPDATE_CHECK=1` 可禁用
231
+
232
+ ## 0.1.9 (2026-03-26)
233
+
234
+ - 全面优化 CLI help 描述,提升 AI Agent 友好性:
235
+ - 根命令增加系统概念图谱(workspace → agent → SA/product/FAQ → issue → debug → monitor)
236
+ - 所有命令组增加业务语义说明(如 SA = Agent 意图匹配和回复逻辑的核心配置)
237
+ - 补全缺失的枚举值(issue status: open|closed、priority: critical|high|medium|low、product status: available|pending|failed 等)
238
+ - 所有 ID 参数标注来源(如"从 agent list 获取")
239
+ - 关键命令标注返回字段结构(如 debug ask 返回 conversation_id + reply[].record_id)
240
+ - `--data`/`--update` 选项列出可更新字段
241
+ - `debug ask --messages` 标清两种 JSON 格式(数组 / { msg_list } 对象)
242
+ - 区分易混淆概念(chat-history ≠ conversation)
243
+ - 同步更新 SKILL.md:新增架构数据流图、ID 获取链路表、完整修复工作流
244
+
245
+ ## 0.1.8 (2026-03-24)
246
+
247
+ - 新增 `issue stats` 命令(各工作空间 Issue 统计,支持日期筛选)
248
+ - 抽取 `buildCookieHeaders` 到 helpers.ts 供 customer-agent API 复用
249
+ - 修复 customer-agent API 返回嵌套 `data.data` 问题
250
+
251
+ ## 0.1.7 (2026-03-24)
252
+
253
+ - `bin.ts` version 改为从 `package.json` 动态读取,不再硬编码
254
+
255
+ ## 0.1.6 (2026-03-24)
256
+
257
+ - 新增 `repair-record list/create/update` 命令(cookie 认证,对接 customer-agent API)
258
+ - 新增 `monitor` 命令(agents/tickets/snapshots/statistics/workspaces)
259
+ - 新增 `customerAgentApiUrl` 配置项(默认 `https://customer-agent.bantouyan.com`)
260
+ - `repair_result` 支持结构化修复推理(`diagnosis_reasoning` / `fix_reasoning`)
261
+ - 补全所有 API 客户端测试(18 个文件 99 个用例)
262
+ - 创建 CLAUDE.md 和 AGENTS.md
263
+
264
+ ## 0.1.5 (2026-03-22)
265
+
266
+ - 增强 SA/产品 CLI 命令并重构 API 层
267
+
268
+ ## 0.1.4 (2026-03-21)
269
+
270
+ - 中文化 CLI、调整超时、内置默认 API 地址、支持消息列表重放
271
+
272
+ ## 0.1.3 (2026-03-20)
273
+
274
+ - 添加 faq、conversation、debug 命令
275
+
276
+ ## 0.1.2 (2026-03-19)
277
+
278
+ - 添加 chat-history 命令
279
+
280
+ ## 0.1.1 (2026-03-18)
281
+
282
+ - 添加 workspace、agent、sa、product、issue 命令
283
+
284
+ ## 0.1.0 (2026-03-17)
285
+
286
+ - 初始版本:auth、config 基础命令
package/README.md CHANGED
@@ -262,12 +262,67 @@ cs-cli product update-sku --agent <id> --sku-id 6072595054179 --update '{"补充
262
262
  | ------------------------------------------------------------------------------------------ | ------ |
263
263
  | `issue list [--agent <id>] [--status <状态>] [--priority <优先级>] [--start <日期>] [--end <日期>]` | 列出工单 |
264
264
  | `issue get <issue_id>` | 获取工单详情 |
265
- | `issue create --title <标题> --content <内容> [--priority <优先级>] [--tags <标签>]` | 创建工单 |
266
- | `issue update <issue_id> --data <json|@file>` | 更新工单 |
265
+ | `issue create --title <标题> --content <内容> [--priority <优先级>] [--tags <标签>]` | 单条创建工单 |
266
+ | `issue create --file <path> \| --stdin [--dry-run] [--concurrency <n>] [--yes]` | **批量**创建工单(JSON 协议见下) |
267
+ | `issue update <issue_id> --data <json|@file>` | 单条更新工单 |
268
+ | `issue update --ids <a,b,c> \| --ids-file <path> --data <json|@file> [--dry-run] [--concurrency <n>] [--yes]` | **批量**更新工单字段(串行 PATCH) |
269
+ | `issue update-owner <issue_id> --owner <user_id\|花名>` | 单条改派负责人(花名自动解析) |
270
+ | `issue update-owner --ids <a,b,c> \| --ids-file <path> --owner <user_id\|花名> [--dry-run] [--yes]` | **批量**改派负责人(走 cs-admin BFF,原子改派 + 自动 Feishu/DingTalk 通知,单批 ≤ 200) |
267
271
  | `issue stats [--start <日期>] [--end <日期>]` | 各工作空间 Issue 统计(open/closed/total) |
268
272
  | `issue comment list <issue_id>` | 列出工单评论 |
269
273
  | `issue comment add <issue_id> --content <内容>` | 添加评论 |
270
274
 
275
+ #### 批量写约定(`update` / `update-owner` / `create --file`)
276
+
277
+ - `--ids "a,b,c"` 与 `--ids-file path.txt`(一行一个 id,`#` 注释、空行忽略)互斥;与单条 `<issue_id>` 互斥。
278
+ - `--owner` 同时接受 `user_id`(≤ 7 位纯数字)和工程师花名。花名经 cs-admin `/api/account-roles` 解析(KA + SMB 合并去重,5 分钟内存 cache),同名 / 模糊歧义会报错列 candidates。`--owner` 拒绝 11 位手机号(Issue #49,老 `--owner-phone` 0.5.0 起硬删)。
279
+ - `--dry-run` 在所有批量子命令上都不会调写接口;`create --file --dry-run` 会真跑 `user_name → conversation_id` 反查,以便提前发现 `NOT_FOUND` / `AMBIGUOUS`。
280
+ - N ≥ 20 且 stdout 是 TTY 时弹二次确认(输入 `yes`);非 TTY 必须显式 `--yes`,否则拒收。
281
+ - 末尾报告统一 schema:`{version:"1", total, success_count, failed_count, skipped_count, aborted, success_ids[], failed[{id,code,msg}], skipped[{id,reason}]}`。退出码:`0` 全成功 / `1` 全失败 / `2` 部分失败 / `130` SIGINT。
282
+ - `update-owner --ids` 走 cs-admin BFF `POST /api/workbench/issues/batch-owner` 单次调用(原子),返回 `{updated, skipped:[{issueId, reason}], notify[]}`,与 `update` 的 runBatch 报告 schema 不同(保留 BFF 原样)。
283
+ - 双后端单 token:servhub Bearer JWT 同时通过 cs-admin BFF 鉴权(实测验证),无需额外登录。
284
+
285
+ #### `issue create --file` JSON 协议
286
+
287
+ 文件解析交给上游(LLM/Agent/脚本)整理成下面这份结构化 JSON,CLI 只做校验 + 反查 + 拼装 + 批量创建。
288
+
289
+ ```jsonc
290
+ {
291
+ "version": "1",
292
+ "workspace_id": "ws_xxx", // 顶层,对所有 issue 生效;缺省时落到默认 workspace
293
+ "issues": [
294
+ {
295
+ "title": "客户反馈:A 问题答非所问", // 必填
296
+ "priority": "high", // 必填,critical|high|medium|low
297
+ "expected": "应该回答 X", // 必填,CLI 把它写进 content 的 ## Expected 段
298
+ "description": "上下文描述", // 可选,拼在 expected 前面
299
+ "conversation_id": "conv_xxx", // 与 user_name 二选一
300
+ "user_name": "张三", // 仅给昵称时,CLI 调 /v1/chat/conversations?user_name= 反查
301
+ "tags": ["badcase"], // 可选
302
+ "source_type": "manual_review", // 可选
303
+ "source_data": {"reviewer": "..."} // 可选
304
+ }
305
+ ]
306
+ }
307
+ ```
308
+
309
+ CLI 把 `description` + `expected` 拼成 `content`:
310
+
311
+ ```
312
+ {description}(如有)
313
+
314
+ ## Expected
315
+ {expected}
316
+ ```
317
+
318
+ `user_name` 反查规则(5 分钟内存 cache,key = `${workspaceId}|${userName}`):
319
+
320
+ - 1 条命中 → 用其 `conversation_id`
321
+ - 0 条 → 该 issue 标记 `NOT_FOUND`,整批拒收(防止半批进创建)
322
+ - 多条 → 该 issue 标记 `AMBIGUOUS`,提示改传显式 `conversation_id`
323
+
324
+ `priority` 不在枚举内、`title`/`expected` 缺失等 → 客户端直接拒收(不打任何接口)。
325
+
271
326
 
272
327
  ### 聊天记录 (`chat-history`)
273
328
 
@@ -555,9 +610,10 @@ cs-cli change-consumer delivery complete <delivery_id> --status completed \
555
610
  | 退出码 | 含义 |
556
611
  | --- | ------------------- |
557
612
  | `0` | 成功 |
558
- | `1` | 业务错误 |
559
- | `2` | 认证错误(未登录或 Token 过期) |
613
+ | `1` | 业务错误 / 批量全部失败 |
614
+ | `2` | 认证错误(未登录或 Token 过期) / 批量部分失败 |
560
615
  | `3` | 网络/服务器错误 |
616
+ | `130` | 批量执行中收到 SIGINT,等飞行中请求结束后退出 |
561
617
 
562
618
 
563
619
  ## 使用示例
@@ -578,6 +634,29 @@ cs-cli issue stats --start 2026-03-01 --end 2026-03-23
578
634
  # 列出工单(按日期筛选)
579
635
  cs-cli issue list --agent <id> --start 2026-03-01T00:00:00 --end 2026-03-22T23:59:59
580
636
 
637
+ # 批量改派(花名 → user_id 自动解析,走 cs-admin BFF 原子改派 + 飞书通知)
638
+ cs-cli issue update-owner --ids iss-1,iss-2,iss-3 --owner xinghe --yes
639
+
640
+ # 批量改派 dry-run(不写):仅解析 owner + 列出 ID
641
+ cs-cli issue update-owner --ids iss-1,iss-2 --owner xinghe --dry-run
642
+
643
+ # pipe-first:先 list 拿 id,再 xargs 批量改派
644
+ cs-cli issue list --status open --priority high \
645
+ | jq -r '.data.items[].issue_id' | tr '\n' ',' \
646
+ | xargs -I{} cs-cli issue update-owner --ids "{}" --owner xinghe --yes
647
+
648
+ # 批量关闭工单(串行 PATCH,部分失败末尾报告)
649
+ cs-cli issue update --ids iss-1,iss-2,iss-3 --data '{"status":"closed"}' --yes
650
+
651
+ # 从文件批量创建工单(user_name 反查 + content 拼装在 CLI 完成)
652
+ cs-cli issue create --file ./issues.json --dry-run # 先预览,发现 NOT_FOUND/AMBIGUOUS
653
+ cs-cli issue create --file ./issues.json --yes # 确认 → 实际创建
654
+
655
+ # 上游 LLM 直接 pipe 进 stdin
656
+ cat meeting-notes.md \
657
+ | <llm-extract-issues> \
658
+ | cs-cli issue create --stdin --yes
659
+
581
660
  # 搜索场景动作
582
661
  cs-cli sa search --agent <id> --keyword "退款"
583
662
 
package/dist/bin.js CHANGED
@@ -180,10 +180,10 @@ function readCache() {
180
180
  return null;
181
181
  }
182
182
  }
183
- function writeCache(cache) {
183
+ function writeCache(cache3) {
184
184
  const dir = getConfigDir();
185
185
  fs2.mkdirSync(dir, { recursive: true });
186
- fs2.writeFileSync(getCachePath(), JSON.stringify(cache, null, 2));
186
+ fs2.writeFileSync(getCachePath(), JSON.stringify(cache3, null, 2));
187
187
  }
188
188
  function compareSemver(current, latest) {
189
189
  const parse = (v) => v.replace(/^v/, "").split(".").map(Number);
@@ -235,10 +235,10 @@ async function checkForUpdate(currentVersion) {
235
235
  return null;
236
236
  }
237
237
  try {
238
- const cache = readCache();
238
+ const cache3 = readCache();
239
239
  const now = Date.now();
240
- if (cache && now - cache.lastCheckedAt < CHECK_INTERVAL_MS) {
241
- return compareSemver(currentVersion, cache.latestVersion) < 0 ? formatUpdateMessage(currentVersion, cache.latestVersion) : null;
240
+ if (cache3 && now - cache3.lastCheckedAt < CHECK_INTERVAL_MS) {
241
+ return compareSemver(currentVersion, cache3.latestVersion) < 0 ? formatUpdateMessage(currentVersion, cache3.latestVersion) : null;
242
242
  }
243
243
  const latestVersion = await fetchLatestVersion();
244
244
  writeCache({ latestVersion, lastCheckedAt: now });
@@ -437,6 +437,7 @@ function isTokenExpired(expiresAt) {
437
437
  var DEFAULT_CS_API_URL = "https://customer-servhub-api.betteryeah.com";
438
438
  var DEFAULT_AI_API_URL = "https://ai-api.betteryeah.com";
439
439
  var DEFAULT_CUSTOMER_AGENT_API_URL = "https://customer-agent.bantouyan.com";
440
+ var DEFAULT_CS_ADMIN_URL = "https://customer-service-admin.betteryeah.com";
440
441
  function getCustomerServiceUrl() {
441
442
  const envUrl = process.env.CS_CS_API_URL;
442
443
  if (envUrl) return envUrl;
@@ -455,6 +456,12 @@ function getCustomerAgentUrl() {
455
456
  const config = readConfig();
456
457
  return config?.customerAgentApiUrl ?? DEFAULT_CUSTOMER_AGENT_API_URL;
457
458
  }
459
+ function getCsAdminUrl() {
460
+ const envUrl = process.env.CS_CS_ADMIN_URL;
461
+ if (envUrl) return envUrl;
462
+ const config = readConfig();
463
+ return config?.csAdminUrl ?? DEFAULT_CS_ADMIN_URL;
464
+ }
458
465
  function getWorkspaceId(overrideWorkspaceId) {
459
466
  const lockState = readEnvLockState();
460
467
  if (lockState.workspaceId) {
@@ -1784,7 +1791,387 @@ async function getIssueStats(opts) {
1784
1791
  return result.data ?? result;
1785
1792
  }
1786
1793
 
1794
+ // src/client/workbench-issues-api.ts
1795
+ async function batchReassignOwner(params) {
1796
+ const request = createRequest();
1797
+ return request(getCsAdminUrl(), "/api/workbench/issues/batch-owner", {
1798
+ method: "POST",
1799
+ body: { issueIds: params.issueIds, ownerUserId: params.ownerUserId }
1800
+ });
1801
+ }
1802
+
1803
+ // src/utils/batch-input.ts
1804
+ import fs5 from "fs";
1805
+ function parseIdsInput(opts) {
1806
+ const hasIds = opts.ids !== void 0 && opts.ids.trim() !== "";
1807
+ const hasFile = opts.idsFile !== void 0 && opts.idsFile.trim() !== "";
1808
+ if (hasIds && hasFile) {
1809
+ throw new Error("--ids \u4E0E --ids-file \u4E92\u65A5\uFF0C\u8BF7\u53EA\u63D0\u4F9B\u5176\u4E00");
1810
+ }
1811
+ if (!hasIds && !hasFile) {
1812
+ throw new Error("\u9700\u8981\u63D0\u4F9B --ids \u6216 --ids-file \u4E4B\u4E00");
1813
+ }
1814
+ let raw;
1815
+ if (hasIds) {
1816
+ raw = opts.ids.split(",").map((s) => s.trim()).filter(Boolean);
1817
+ } else {
1818
+ const filePath = opts.idsFile;
1819
+ let content;
1820
+ try {
1821
+ content = fs5.readFileSync(filePath, "utf8");
1822
+ } catch (err) {
1823
+ const msg = err instanceof Error ? err.message : String(err);
1824
+ throw new Error(`\u8BFB\u53D6 --ids-file \u5931\u8D25: ${msg}`);
1825
+ }
1826
+ raw = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
1827
+ }
1828
+ if (raw.length === 0) {
1829
+ throw new Error("--ids / --ids-file \u672A\u89E3\u6790\u5230\u4EFB\u4F55 id");
1830
+ }
1831
+ const seen = /* @__PURE__ */ new Set();
1832
+ const ids = [];
1833
+ for (const id of raw) {
1834
+ if (!seen.has(id)) {
1835
+ seen.add(id);
1836
+ ids.push(id);
1837
+ }
1838
+ }
1839
+ return ids;
1840
+ }
1841
+
1842
+ // src/utils/issue-batch-input.ts
1843
+ import fs6 from "fs";
1844
+ var PRIORITY_VALUES = ["critical", "high", "medium", "low"];
1845
+ function readSource(opts) {
1846
+ const hasFile = typeof opts.file === "string" && opts.file.trim() !== "";
1847
+ const hasStdin = typeof opts.stdin === "string";
1848
+ if (hasFile && hasStdin) {
1849
+ throw new Error("--file \u4E0E --stdin \u4E92\u65A5\uFF0C\u8BF7\u53EA\u63D0\u4F9B\u5176\u4E00");
1850
+ }
1851
+ if (!hasFile && !hasStdin) {
1852
+ throw new Error("\u9700\u8981\u63D0\u4F9B --file <path> \u6216\u901A\u8FC7 stdin \u4F20\u5165 JSON");
1853
+ }
1854
+ if (hasFile) {
1855
+ try {
1856
+ return fs6.readFileSync(opts.file, "utf8");
1857
+ } catch (err) {
1858
+ const msg = err instanceof Error ? err.message : String(err);
1859
+ throw new Error(`\u8BFB\u53D6 --file \u5931\u8D25: ${msg}`);
1860
+ }
1861
+ }
1862
+ return opts.stdin;
1863
+ }
1864
+ function parseIssueBatchInput(opts) {
1865
+ const raw = readSource(opts).trim();
1866
+ if (!raw) {
1867
+ throw new Error("\u8F93\u5165 JSON \u4E3A\u7A7A");
1868
+ }
1869
+ let parsed;
1870
+ try {
1871
+ parsed = JSON.parse(raw);
1872
+ } catch (err) {
1873
+ const msg = err instanceof Error ? err.message : String(err);
1874
+ throw new Error(`JSON \u89E3\u6790\u5931\u8D25: ${msg}`);
1875
+ }
1876
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1877
+ throw new Error("\u9876\u5C42\u5FC5\u987B\u662F JSON object\uFF08\u542B version + issues[]\uFF09");
1878
+ }
1879
+ const root = parsed;
1880
+ const version2 = typeof root.version === "string" ? root.version : "1";
1881
+ const issuesRaw = root.issues;
1882
+ if (!Array.isArray(issuesRaw)) {
1883
+ throw new Error("\u9876\u5C42\u7F3A\u5C11\u6570\u7EC4\u5B57\u6BB5 issues[]");
1884
+ }
1885
+ if (issuesRaw.length === 0) {
1886
+ throw new Error("issues[] \u4E3A\u7A7A\uFF0C\u65E0\u53EF\u521B\u5EFA");
1887
+ }
1888
+ const workspaceId = typeof root.workspace_id === "string" ? root.workspace_id : void 0;
1889
+ const issues = issuesRaw.map((it, idx) => validateIssue(it, idx));
1890
+ return { version: version2, workspace_id: workspaceId, issues };
1891
+ }
1892
+ function validateIssue(value, idx) {
1893
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1894
+ throw new Error(`issues[${idx}] \u4E0D\u662F object`);
1895
+ }
1896
+ const v = value;
1897
+ const where = `issues[${idx}]`;
1898
+ const title = requireString(v.title, `${where}.title`);
1899
+ const priority = requirePriority(v.priority, `${where}.priority`);
1900
+ const expected = requireString(v.expected, `${where}.expected`);
1901
+ const conversation_id = optionalString(v.conversation_id, `${where}.conversation_id`);
1902
+ const user_name = optionalString(v.user_name, `${where}.user_name`);
1903
+ if (!conversation_id && !user_name) {
1904
+ throw new Error(`${where} \u5FC5\u987B\u63D0\u4F9B conversation_id \u6216 user_name \u4E4B\u4E00`);
1905
+ }
1906
+ const description = optionalString(v.description, `${where}.description`);
1907
+ let tags;
1908
+ if (v.tags !== void 0) {
1909
+ if (!Array.isArray(v.tags) || !v.tags.every((t) => typeof t === "string")) {
1910
+ throw new Error(`${where}.tags \u5FC5\u987B\u662F string[]`);
1911
+ }
1912
+ tags = v.tags;
1913
+ }
1914
+ const source_type = optionalString(v.source_type, `${where}.source_type`);
1915
+ let source_data;
1916
+ if (v.source_data !== void 0) {
1917
+ if (!v.source_data || typeof v.source_data !== "object" || Array.isArray(v.source_data)) {
1918
+ throw new Error(`${where}.source_data \u5FC5\u987B\u662F object`);
1919
+ }
1920
+ source_data = v.source_data;
1921
+ }
1922
+ return {
1923
+ title,
1924
+ priority,
1925
+ expected,
1926
+ description,
1927
+ conversation_id,
1928
+ user_name,
1929
+ tags,
1930
+ source_type,
1931
+ source_data
1932
+ };
1933
+ }
1934
+ function requireString(v, where) {
1935
+ if (typeof v !== "string" || v.trim() === "") {
1936
+ throw new Error(`${where} \u5FC5\u586B\uFF0C\u4E14\u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32`);
1937
+ }
1938
+ return v;
1939
+ }
1940
+ function optionalString(v, where) {
1941
+ if (v === void 0 || v === null) return void 0;
1942
+ if (typeof v !== "string") {
1943
+ throw new Error(`${where} \u5FC5\u987B\u662F\u5B57\u7B26\u4E32`);
1944
+ }
1945
+ return v.trim() === "" ? void 0 : v;
1946
+ }
1947
+ function requirePriority(v, where) {
1948
+ if (typeof v !== "string" || !PRIORITY_VALUES.includes(v)) {
1949
+ throw new Error(
1950
+ `${where} \u5FC5\u987B\u662F ${PRIORITY_VALUES.join("|")} \u4E4B\u4E00\uFF0C\u6536\u5230: ${JSON.stringify(v)}`
1951
+ );
1952
+ }
1953
+ return v;
1954
+ }
1955
+ function buildContent(issue) {
1956
+ const head = issue.description?.trim();
1957
+ if (head) {
1958
+ return `${head}
1959
+
1960
+ ## Expected
1961
+ ${issue.expected}`;
1962
+ }
1963
+ return `## Expected
1964
+ ${issue.expected}`;
1965
+ }
1966
+
1967
+ // src/client/account-roles-api.ts
1968
+ async function listAccountRoles(opts = {}) {
1969
+ const request = createRequest();
1970
+ const query = {};
1971
+ if (opts.role) query.role = opts.role;
1972
+ if (opts.includeInactive) query.includeInactive = "1";
1973
+ const result = await request(getCsAdminUrl(), "/api/account-roles", {
1974
+ method: "GET",
1975
+ query
1976
+ });
1977
+ return result.rows ?? [];
1978
+ }
1979
+
1980
+ // src/utils/resolve-owner.ts
1981
+ var CACHE_TTL_MS = 5 * 60 * 1e3;
1982
+ var cache = null;
1983
+ async function getEngineers() {
1984
+ const now = Date.now();
1985
+ if (cache && cache.expireAt > now) return cache.engineers;
1986
+ const [ka, smb] = await Promise.all([
1987
+ listAccountRoles({ role: "KA_ENGINEER" }),
1988
+ listAccountRoles({ role: "SMB_ENGINEER" })
1989
+ ]);
1990
+ const merged = mergeAndDedupe(ka, smb);
1991
+ cache = { engineers: merged, expireAt: now + CACHE_TTL_MS };
1992
+ return merged;
1993
+ }
1994
+ function mergeAndDedupe(...lists) {
1995
+ const seen = /* @__PURE__ */ new Map();
1996
+ for (const list of lists) {
1997
+ for (const row of list) {
1998
+ const existing = seen.get(row.csAdminUserId);
1999
+ if (!existing) {
2000
+ seen.set(row.csAdminUserId, { ...row });
2001
+ } else {
2002
+ const roles = /* @__PURE__ */ new Set([...existing.roles, ...row.roles]);
2003
+ existing.roles = [...roles];
2004
+ }
2005
+ }
2006
+ }
2007
+ return [...seen.values()].sort((a, b) => a.engineerName.localeCompare(b.engineerName));
2008
+ }
2009
+ async function resolveOwner(input) {
2010
+ const trimmed = input.trim();
2011
+ if (!trimmed) throw new Error("--owner \u4E0D\u80FD\u4E3A\u7A7A");
2012
+ if (/^\d+$/.test(trimmed)) {
2013
+ if (trimmed.length === 11) {
2014
+ throw new Error("--owner \u4E0D\u518D\u63A5\u53D7\u624B\u673A\u53F7\uFF08\u5DF2\u5E9F\u5F03\uFF09\u3002\u8BF7\u4F20 user_id \u6216\u82B1\u540D");
2015
+ }
2016
+ const n = Number(trimmed);
2017
+ if (!Number.isInteger(n) || n <= 0) {
2018
+ throw new Error(`--owner \u6570\u5B57\u89E3\u6790\u5931\u8D25: ${input}`);
2019
+ }
2020
+ return n;
2021
+ }
2022
+ const engineers = await getEngineers();
2023
+ const exact = engineers.filter((e) => e.engineerName === trimmed);
2024
+ if (exact.length === 1) return exact[0].csAdminUserId;
2025
+ if (exact.length > 1) {
2026
+ const list = exact.map((e) => `${e.engineerName}(${e.csAdminUserId})`).join(", ");
2027
+ throw new Error(`--owner "${input}" \u540C\u540D\u591A\u4EBA\uFF1A${list}\u3002\u8BF7\u6539\u4F20 user_id`);
2028
+ }
2029
+ const partial = engineers.filter(
2030
+ (e) => e.engineerName.toLowerCase().includes(trimmed.toLowerCase())
2031
+ );
2032
+ if (partial.length === 1) return partial[0].csAdminUserId;
2033
+ if (partial.length > 1) {
2034
+ const list = partial.map((e) => `${e.engineerName}(${e.csAdminUserId})`).join(", ");
2035
+ throw new Error(`--owner "${input}" \u6A21\u7CCA\u5339\u914D\u591A\u4EBA\uFF1A${list}\u3002\u8BF7\u6539\u7528\u7CBE\u786E\u82B1\u540D\u6216 user_id`);
2036
+ }
2037
+ throw new Error(`--owner "${input}" \u672A\u627E\u5230\u5339\u914D\u7684\u5DE5\u7A0B\u5E08\uFF08\u5171 ${engineers.length} \u4EBA\u5728\u518C\uFF09`);
2038
+ }
2039
+
2040
+ // src/utils/resolve-session.ts
2041
+ var CACHE_TTL_MS2 = 5 * 60 * 1e3;
2042
+ var cache2 = /* @__PURE__ */ new Map();
2043
+ async function resolveSession(opts) {
2044
+ const userName = opts.userName.trim();
2045
+ if (!userName) throw new Error("user_name \u4E0D\u80FD\u4E3A\u7A7A");
2046
+ const cacheKey = `${opts.workspaceId ?? ""}|${userName}`;
2047
+ const now = Date.now();
2048
+ const hit = cache2.get(cacheKey);
2049
+ if (hit && hit.expireAt > now) return hit.conversationId;
2050
+ const resp = await listConversations({
2051
+ userName,
2052
+ workspaceId: opts.workspaceId,
2053
+ pageSize: opts.pageSize ?? 5
2054
+ });
2055
+ const list = (resp.list ?? []).filter((c) => typeof c.conversation_id === "string");
2056
+ if (list.length === 0) {
2057
+ throw new Error(`user_name "${userName}" \u672A\u5339\u914D\u5230\u4EFB\u4F55\u4F1A\u8BDD\uFF08NOT_FOUND\uFF09`);
2058
+ }
2059
+ if (list.length > 1) {
2060
+ const preview = list.slice(0, 3).map((c) => c.conversation_id).join(", ");
2061
+ throw new Error(
2062
+ `user_name "${userName}" \u547D\u4E2D ${list.length} \u6761\u4F1A\u8BDD\uFF08AMBIGUOUS\uFF09\uFF1A${preview}${list.length > 3 ? " ..." : ""}\u3002\u8BF7\u6539\u4F20 conversation_id`
2063
+ );
2064
+ }
2065
+ const conversationId = list[0].conversation_id;
2066
+ cache2.set(cacheKey, { conversationId, expireAt: now + CACHE_TTL_MS2 });
2067
+ return conversationId;
2068
+ }
2069
+
2070
+ // src/utils/run-batch.ts
2071
+ import readline from "readline";
2072
+ var DEFAULT_CONCURRENCY = 5;
2073
+ async function runBatch(opts) {
2074
+ const concurrency = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
2075
+ const results = [];
2076
+ let aborted = false;
2077
+ const sigintHandler = () => {
2078
+ if (!aborted) {
2079
+ aborted = true;
2080
+ outputInfo("\u6536\u5230 SIGINT\uFF0C\u7B49\u5F85\u98DE\u884C\u4E2D\u8BF7\u6C42\u7ED3\u675F\u540E\u9000\u51FA...");
2081
+ }
2082
+ };
2083
+ process.on("SIGINT", sigintHandler);
2084
+ try {
2085
+ for (let i = 0; i < opts.ids.length; i += concurrency) {
2086
+ if (aborted) break;
2087
+ const chunk = opts.ids.slice(i, i + concurrency);
2088
+ const settled = await Promise.all(
2089
+ chunk.map(async (id) => {
2090
+ try {
2091
+ return await opts.perItem(id);
2092
+ } catch (err) {
2093
+ return mapErrorToFailure(id, err);
2094
+ }
2095
+ })
2096
+ );
2097
+ results.push(...settled);
2098
+ }
2099
+ } finally {
2100
+ process.off("SIGINT", sigintHandler);
2101
+ }
2102
+ return buildReport(results, aborted);
2103
+ }
2104
+ function mapErrorToFailure(id, err) {
2105
+ if (err instanceof APIError) {
2106
+ return { status: "failed", id, code: `HTTP_${err.statusCode}`, msg: err.message };
2107
+ }
2108
+ const msg = err instanceof Error ? err.message : String(err);
2109
+ return { status: "failed", id, code: "INTERNAL", msg };
2110
+ }
2111
+ function buildReport(results, aborted) {
2112
+ const report = {
2113
+ version: "1",
2114
+ total: results.length,
2115
+ success_count: 0,
2116
+ failed_count: 0,
2117
+ skipped_count: 0,
2118
+ aborted,
2119
+ success_ids: [],
2120
+ failed: [],
2121
+ skipped: []
2122
+ };
2123
+ for (const r of results) {
2124
+ if (r.status === "success") {
2125
+ report.success_count++;
2126
+ report.success_ids.push(r.id);
2127
+ } else if (r.status === "failed") {
2128
+ report.failed_count++;
2129
+ report.failed.push({ id: r.id, code: r.code, msg: r.msg });
2130
+ } else {
2131
+ report.skipped_count++;
2132
+ report.skipped.push({ id: r.id, reason: r.reason });
2133
+ }
2134
+ }
2135
+ return report;
2136
+ }
2137
+ function reportToExitCode(report) {
2138
+ if (report.aborted) return 130;
2139
+ if (report.failed_count === 0) return 0;
2140
+ if (report.failed_count === report.total) return 1;
2141
+ return 2;
2142
+ }
2143
+ var DEFAULT_CONFIRM_THRESHOLD = 20;
2144
+ async function confirmBatch(opts) {
2145
+ const threshold = opts.threshold ?? DEFAULT_CONFIRM_THRESHOLD;
2146
+ if (opts.count < threshold) return;
2147
+ if (opts.yes) return;
2148
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2149
+ throw new Error(`\u975E TTY \u73AF\u5883\u4E0B\u8981 ${opts.action} ${opts.count} \u6761\uFF0C\u9700\u663E\u5F0F --yes \u624D\u80FD\u6267\u884C`);
2150
+ }
2151
+ const rl = readline.createInterface({
2152
+ input: process.stdin,
2153
+ output: process.stderr
2154
+ });
2155
+ try {
2156
+ const answer = await new Promise((resolve) => {
2157
+ rl.question(`\u5373\u5C06${opts.action} ${opts.count} \u6761\u8BB0\u5F55\u3002\u7EE7\u7EED\uFF1F\u8F93\u5165 yes \u786E\u8BA4: `, resolve);
2158
+ });
2159
+ if (answer.trim().toLowerCase() !== "yes") {
2160
+ throw new Error("\u5DF2\u53D6\u6D88");
2161
+ }
2162
+ } finally {
2163
+ rl.close();
2164
+ }
2165
+ }
2166
+
1787
2167
  // src/commands/issue.ts
2168
+ async function readAllStdin() {
2169
+ const chunks = [];
2170
+ for await (const chunk of process.stdin) {
2171
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
2172
+ }
2173
+ return Buffer.concat(chunks).toString("utf8");
2174
+ }
1788
2175
  function registerIssueCommand(program2) {
1789
2176
  const issue = program2.command("issue").description(
1790
2177
  "\u5DE5\u5355\uFF08Issue\uFF09\u7BA1\u7406 \u2014\u2014 \u8BB0\u5F55 Agent \u56DE\u590D\u5F02\u5E38\u3001\u5BA2\u6237\u6295\u8BC9\u7B49\u5F85\u5904\u7406\u95EE\u9898\u3002\u5DE5\u5355\u662F AI \u4FEE\u590D\u6D41\u7A0B\uFF08issue-repair\uFF09\u7684\u8F93\u5165\u6E90"
@@ -1825,63 +2212,219 @@ function registerIssueCommand(program2) {
1825
2212
  process.exit(toExitCode(err));
1826
2213
  }
1827
2214
  });
1828
- issue.command("create").description("\u624B\u52A8\u521B\u5EFA\u5DE5\u5355").requiredOption("--title <title>", "\u5DE5\u5355\u6807\u9898\uFF08\u7B80\u8981\u63CF\u8FF0\u95EE\u9898\uFF09").requiredOption("--content <content>", "\u5DE5\u5355\u5185\u5BB9\uFF08\u8BE6\u7EC6\u63CF\u8FF0\u95EE\u9898\u73B0\u8C61\u548C\u671F\u671B\u7ED3\u679C\uFF09").option("--priority <priority>", "\u4F18\u5148\u7EA7: critical | high | medium | low").option("--tags <tags...>", "\u6807\u7B7E\uFF08\u591A\u4E2A\u7A7A\u683C\u5206\u9694\uFF0C\u7528\u4E8E\u5206\u7C7B\u7B5B\u9009\uFF09").option("--conversation-id <id>", "\u5173\u8054\u7684\u4F1A\u8BDD ID").option("--record-id <id>", "\u5173\u8054\u7684\u6D88\u606F\u8BB0\u5F55 ID").option("--workspace-id <id>", "\u5DE5\u4F5C\u7A7A\u95F4 ID\uFF08\u9ED8\u8BA4\u4ECE\u73AF\u5883\u53D8\u91CF\u8BFB\u53D6\uFF09").option("--source-type <type>", "\u6765\u6E90\u7C7B\u578B").option("--source-data <json>", "\u6765\u6E90\u6570\u636E\uFF08JSON \u5B57\u7B26\u4E32\uFF09").action(async (opts) => {
2215
+ issue.command("create").description(
2216
+ "\u521B\u5EFA\u5DE5\u5355\u3002\u5355\u6761\uFF1A--title + --content\uFF08+ \u53EF\u9009\u5B57\u6BB5\uFF09\u3002\u6279\u91CF\uFF1A--file <path> \u6216 --stdin \u8BFB JSON\uFF08\u542B workspace_id + issues[]\uFF0C\u6BCF\u6761\u5FC5\u586B title/priority/expected \u548C conversation_id|user_name \u4E8C\u9009\u4E00\uFF09\u3002\u6279\u91CF\u6A21\u5F0F N>20 \u4E14 TTY \u9700\u8981\u4E8C\u6B21\u786E\u8BA4\uFF1B\u975E TTY \u5FC5\u987B\u663E\u5F0F --yes"
2217
+ ).option("--title <title>", "[\u5355\u6761] \u5DE5\u5355\u6807\u9898\uFF08\u7B80\u8981\u63CF\u8FF0\u95EE\u9898\uFF09").option("--content <content>", "[\u5355\u6761] \u5DE5\u5355\u5185\u5BB9\uFF08\u8BE6\u7EC6\u63CF\u8FF0\u95EE\u9898\u73B0\u8C61\u548C\u671F\u671B\u7ED3\u679C\uFF09").option("--priority <priority>", "[\u5355\u6761] \u4F18\u5148\u7EA7: critical | high | medium | low").option("--tags <tags...>", "[\u5355\u6761] \u6807\u7B7E\uFF08\u591A\u4E2A\u7A7A\u683C\u5206\u9694\uFF0C\u7528\u4E8E\u5206\u7C7B\u7B5B\u9009\uFF09").option("--conversation-id <id>", "[\u5355\u6761] \u5173\u8054\u7684\u4F1A\u8BDD ID").option("--record-id <id>", "[\u5355\u6761] \u5173\u8054\u7684\u6D88\u606F\u8BB0\u5F55 ID").option("--workspace-id <id>", "[\u5355\u6761] \u5DE5\u4F5C\u7A7A\u95F4 ID\uFF08\u9ED8\u8BA4\u4ECE\u73AF\u5883\u53D8\u91CF\u8BFB\u53D6\uFF09").option("--source-type <type>", "[\u5355\u6761] \u6765\u6E90\u7C7B\u578B").option("--source-data <json>", "[\u5355\u6761] \u6765\u6E90\u6570\u636E\uFF08JSON \u5B57\u7B26\u4E32\uFF09").option("--file <path>", "[\u6279\u91CF] \u4ECE JSON \u6587\u4EF6\u8BFB\u8F93\u5165\uFF08\u4E0E --stdin \u4E92\u65A5\uFF09").option("--stdin", "[\u6279\u91CF] \u4ECE stdin \u8BFB JSON\uFF08\u4E0E --file \u4E92\u65A5\uFF09", false).option("--dry-run", "[\u6279\u91CF] \u9884\u89C8\uFF1A\u5B8C\u6210 user_name \u53CD\u67E5 + content \u62FC\u88C5\uFF0C\u4F46\u4E0D\u8C03\u521B\u5EFA\u63A5\u53E3", false).option("--concurrency <n>", "[\u6279\u91CF] \u5E76\u53D1\u6570\uFF08\u9ED8\u8BA4 5\uFF09", "5").option("--yes", "[\u6279\u91CF] \u8DF3\u8FC7 N>=20 \u4E8C\u6B21\u786E\u8BA4\uFF08\u975E TTY \u5FC5\u987B\u663E\u5F0F\u4F20\uFF09", false).action(async (opts) => {
2218
+ const hasBatch = Boolean(opts.file) || opts.stdin === true;
2219
+ const hasSingleLike = Boolean(opts.title || opts.content);
2220
+ if (hasBatch && hasSingleLike) {
2221
+ reportCaughtError(
2222
+ new Error("\u4E0D\u80FD\u540C\u65F6\u63D0\u4F9B\u5355\u6761\u5B57\u6BB5\uFF08--title/--content\uFF09\u548C\u6279\u91CF\u5B57\u6BB5\uFF08--file/--stdin\uFF09")
2223
+ );
2224
+ process.exit(1);
2225
+ return;
2226
+ }
2227
+ if (!hasBatch) {
2228
+ try {
2229
+ if (!opts.title || !opts.content) {
2230
+ throw new Error(
2231
+ "\u5355\u6761\u521B\u5EFA\u9700\u63D0\u4F9B --title \u548C --content\uFF08\u6216\u6539\u7528 --file/--stdin \u6279\u91CF\u6A21\u5F0F\uFF09"
2232
+ );
2233
+ }
2234
+ const data = await createIssue({
2235
+ title: opts.title,
2236
+ content: opts.content,
2237
+ priority: opts.priority,
2238
+ tags: opts.tags,
2239
+ conversation_id: opts.conversationId,
2240
+ record_id: opts.recordId,
2241
+ workspace_id: opts.workspaceId,
2242
+ source_type: opts.sourceType,
2243
+ source_data: opts.sourceData ? JSON.parse(opts.sourceData) : void 0
2244
+ });
2245
+ formatOutput({ success: true, data }, program2.opts().table);
2246
+ } catch (err) {
2247
+ reportCaughtError(err);
2248
+ process.exit(toExitCode(err));
2249
+ }
2250
+ return;
2251
+ }
2252
+ let batchReport = null;
1829
2253
  try {
1830
- const data = await createIssue({
1831
- title: opts.title,
1832
- content: opts.content,
1833
- priority: opts.priority,
1834
- tags: opts.tags,
1835
- conversation_id: opts.conversationId,
1836
- record_id: opts.recordId,
1837
- workspace_id: opts.workspaceId,
1838
- source_type: opts.sourceType,
1839
- source_data: opts.sourceData ? JSON.parse(opts.sourceData) : void 0
2254
+ const stdinContent = opts.stdin ? await readAllStdin() : void 0;
2255
+ const parsed = parseIssueBatchInput({ file: opts.file, stdin: stdinContent });
2256
+ const workspaceId = parsed.workspace_id;
2257
+ const resolved = await resolveSessionsForIssues(parsed.issues, workspaceId);
2258
+ if (opts.dryRun) {
2259
+ formatOutput(
2260
+ {
2261
+ success: true,
2262
+ data: {
2263
+ dry_run: true,
2264
+ version: parsed.version,
2265
+ workspace_id: workspaceId,
2266
+ total: parsed.issues.length,
2267
+ will_create: resolved.map((r) => ({
2268
+ title: r.issue.title,
2269
+ priority: r.issue.priority,
2270
+ conversation_id: r.conversationId,
2271
+ tags: r.issue.tags,
2272
+ resolved_from_user_name: r.issue.conversation_id ? void 0 : r.issue.user_name,
2273
+ preview_content: buildContent(r.issue)
2274
+ }))
2275
+ }
2276
+ },
2277
+ program2.opts().table
2278
+ );
2279
+ return;
2280
+ }
2281
+ await confirmBatch({ count: parsed.issues.length, action: "\u521B\u5EFA", yes: opts.yes });
2282
+ const concurrency = Number(opts.concurrency);
2283
+ if (!Number.isFinite(concurrency) || concurrency < 1) {
2284
+ throw new Error(`--concurrency \u5FC5\u987B\u4E3A\u6B63\u6574\u6570\uFF0C\u6536\u5230: ${opts.concurrency}`);
2285
+ }
2286
+ const ids = parsed.issues.map((_, idx) => `idx-${idx}`);
2287
+ const indexed = new Map(resolved.map((r, idx) => [`idx-${idx}`, r]));
2288
+ batchReport = await runBatch({
2289
+ ids,
2290
+ concurrency,
2291
+ perItem: async (id) => {
2292
+ const r = indexed.get(id);
2293
+ if (!r) throw new Error(`internal: missing resolved entry for ${id}`);
2294
+ const data = await createIssue({
2295
+ title: r.issue.title,
2296
+ content: buildContent(r.issue),
2297
+ priority: r.issue.priority,
2298
+ tags: r.issue.tags,
2299
+ conversation_id: r.conversationId,
2300
+ workspace_id: workspaceId,
2301
+ source_type: r.issue.source_type,
2302
+ source_data: r.issue.source_data
2303
+ });
2304
+ return { status: "success", id, data };
2305
+ }
1840
2306
  });
1841
- formatOutput({ success: true, data }, program2.opts().table);
2307
+ formatOutput({ success: true, data: batchReport }, program2.opts().table);
1842
2308
  } catch (err) {
1843
2309
  reportCaughtError(err);
1844
2310
  process.exit(toExitCode(err));
2311
+ return;
2312
+ }
2313
+ if (batchReport !== null) {
2314
+ const code = reportToExitCode(batchReport);
2315
+ if (code !== 0) process.exit(code);
1845
2316
  }
1846
2317
  });
1847
- issue.command("update").description("\u66F4\u65B0\u5DE5\u5355\u5B57\u6BB5\uFF08\u5982 status\u3001priority\u3001tags\u3001title\u3001content\uFF09").argument("<issue_id>", "\u5DE5\u5355 ID\uFF08\u4ECE issue list \u83B7\u53D6\uFF09").requiredOption(
2318
+ issue.command("update").description(
2319
+ "\u66F4\u65B0\u5DE5\u5355\u5B57\u6BB5\uFF08\u5982 status\u3001priority\u3001tags\u3001title\u3001content\uFF09\u3002\u5355\u6761\uFF1A\u4F20 <issue_id>\u3002\u6279\u91CF\uFF1A\u4F20 --ids \u6216 --ids-file\uFF0C\u6240\u6709 ID \u5E94\u7528\u540C\u4E00 --data\u3002\u6279\u91CF\u6A21\u5F0F\u4E0B N>20 \u4E14 TTY \u9700\u8981\u4E8C\u6B21\u786E\u8BA4\uFF1B\u975E TTY \u5FC5\u987B\u663E\u5F0F --yes"
2320
+ ).argument("[issue_id]", "\u5DE5\u5355 ID\uFF08\u5355\u6761\u66F4\u65B0\u65F6\u5FC5\u586B\uFF1B\u6279\u91CF\u66F4\u65B0\u8D70 --ids / --ids-file\uFF09").requiredOption(
1848
2321
  "--data <json>",
1849
2322
  "JSON \u6570\u636E\u6216 @\u6587\u4EF6\u8DEF\u5F84\u3002\u53EF\u66F4\u65B0: status\uFF08open|closed\uFF09\u3001priority\u3001tags\u3001title\u3001content"
1850
- ).action(async (issueId, opts) => {
2323
+ ).option("--ids <ids>", "\u6279\u91CF\u66F4\u65B0\uFF1A\u9017\u53F7\u5206\u9694\u7684\u5DE5\u5355 ID\uFF08\u4E0E <issue_id> \u4E92\u65A5\uFF09").option("--ids-file <path>", "\u6279\u91CF\u66F4\u65B0\uFF1A\u4ECE\u6587\u4EF6\u8BFB\u53D6 ID\uFF08\u6BCF\u884C\u4E00\u4E2A\uFF1B# \u5F00\u5934\u6CE8\u91CA\uFF09").option("--dry-run", "\u6279\u91CF\u6A21\u5F0F\u9884\u89C8\uFF1A\u53EA\u6253\u5370 will-update \u5217\u8868\uFF0C\u4E0D\u8C03\u5199\u63A5\u53E3", false).option("--concurrency <n>", "\u6279\u91CF\u5E76\u53D1\u6570\uFF08\u9ED8\u8BA4 5\uFF09", "5").option("--yes", "\u6279\u91CF\u6A21\u5F0F\u8DF3\u8FC7\u4E8C\u6B21\u786E\u8BA4\uFF08\u975E TTY \u5FC5\u987B\u663E\u5F0F\u4F20\uFF09", false).action(async (issueId, opts) => {
2324
+ const hasSingle = issueId !== void 0;
2325
+ const hasBatch = opts.ids !== void 0 || opts.idsFile !== void 0;
2326
+ if (hasSingle && hasBatch) {
2327
+ reportCaughtError(new Error("\u4E0D\u80FD\u540C\u65F6\u63D0\u4F9B <issue_id> \u548C --ids/--ids-file"));
2328
+ process.exit(1);
2329
+ return;
2330
+ }
2331
+ if (!hasSingle && !hasBatch) {
2332
+ reportCaughtError(new Error("\u9700\u8981\u63D0\u4F9B <issue_id> \u6216 --ids/--ids-file \u4E4B\u4E00"));
2333
+ process.exit(1);
2334
+ return;
2335
+ }
2336
+ let batchReport = null;
1851
2337
  try {
1852
2338
  const body = parseDataOption(opts.data);
1853
- const data = await updateIssue(issueId, body);
1854
- formatOutput({ success: true, data }, program2.opts().table);
2339
+ if (hasSingle) {
2340
+ const data = await updateIssue(issueId, body);
2341
+ formatOutput({ success: true, data }, program2.opts().table);
2342
+ return;
2343
+ }
2344
+ const ids = parseIdsInput({ ids: opts.ids, idsFile: opts.idsFile });
2345
+ if (opts.dryRun) {
2346
+ formatOutput(
2347
+ {
2348
+ success: true,
2349
+ data: {
2350
+ dry_run: true,
2351
+ total: ids.length,
2352
+ ids,
2353
+ will_apply: body
2354
+ }
2355
+ },
2356
+ program2.opts().table
2357
+ );
2358
+ return;
2359
+ }
2360
+ await confirmBatch({ count: ids.length, action: "\u66F4\u65B0", yes: opts.yes });
2361
+ const concurrency = Number(opts.concurrency);
2362
+ if (!Number.isFinite(concurrency) || concurrency < 1) {
2363
+ throw new Error(`--concurrency \u5FC5\u987B\u4E3A\u6B63\u6574\u6570\uFF0C\u6536\u5230: ${opts.concurrency}`);
2364
+ }
2365
+ batchReport = await runBatch({
2366
+ ids,
2367
+ concurrency,
2368
+ perItem: async (id) => {
2369
+ const data = await updateIssue(id, body);
2370
+ return { status: "success", id, data };
2371
+ }
2372
+ });
2373
+ formatOutput({ success: true, data: batchReport }, program2.opts().table);
1855
2374
  } catch (err) {
1856
2375
  reportCaughtError(err);
1857
2376
  process.exit(toExitCode(err));
2377
+ return;
2378
+ }
2379
+ if (batchReport !== null) {
2380
+ const code = reportToExitCode(batchReport);
2381
+ if (code !== 0) process.exit(code);
1858
2382
  }
1859
2383
  });
1860
2384
  issue.command("update-owner").description(
1861
- "\u66F4\u65B0\u5DE5\u5355\u8D1F\u8D23\u4EBA\uFF08owner\uFF09\u3002\u4EC5\u4E8C\u9009\u4E00\uFF1A--owner <user_id> \u6216 --owner-phone <\u624B\u673A\u53F7>\uFF0C\u540E\u8005\u7531\u670D\u52A1\u7AEF\u89E3\u6790\u4E3A user_id\u3002\u6CE8\u610F\uFF1A\u540E\u7AEF\u9700\u5F00\u542F\u5BF9 owner / owner_phone \u5B57\u6BB5\u7684\u652F\u6301\u540E\u8BE5\u547D\u4EE4\u624D\u80FD\u751F\u6548"
1862
- ).argument("<issue_id>", "\u5DE5\u5355 ID\uFF08\u4ECE issue list \u83B7\u53D6\uFF09").option("--owner <user_id>", "\u8D1F\u8D23\u4EBA user_id\uFF08\u6B63\u6574\u6570\uFF09").option("--owner-phone <phone>", "\u8D1F\u8D23\u4EBA\u624B\u673A\u53F7\uFF08\u7531\u670D\u52A1\u7AEF\u89E3\u6790\u4E3A user_id\uFF09").action(async (issueId, opts) => {
2385
+ "\u66F4\u65B0\u5DE5\u5355\u8D1F\u8D23\u4EBA\u3002--owner \u63A5\u53D7 user_id\uFF08\u22647 \u4F4D\u7EAF\u6570\u5B57\uFF09\u6216\u82B1\u540D\uFF08\u81EA\u52A8\u901A\u8FC7 cs-admin /api/account-roles \u89E3\u6790\uFF09\u3002\u5355\u6761\uFF1A\u4F20 <issue_id>\u3002\u6279\u91CF\uFF1A\u4F20 --ids \u6216 --ids-file\uFF0C\u8D70 cs-admin BFF \u7684 batch-owner\uFF08\u5355\u6B21\u8C03\u7528\u3001\u539F\u5B50\u6539\u6D3E\u3001\u81EA\u52A8 Feishu/DingTalk \u901A\u77E5\uFF09"
2386
+ ).argument("[issue_id]", "\u5DE5\u5355 ID\uFF08\u5355\u6761\u66F4\u65B0\u65F6\u5FC5\u586B\uFF1B\u6279\u91CF\u8D70 --ids / --ids-file\uFF09").requiredOption("--owner <user_id_or_name>", "\u8D1F\u8D23\u4EBA user_id \u6216\u82B1\u540D").option("--ids <ids>", "\u6279\u91CF\u6539\u6D3E\uFF1A\u9017\u53F7\u5206\u9694\u7684\u5DE5\u5355 ID\uFF08\u4E0E <issue_id> \u4E92\u65A5\uFF09").option("--ids-file <path>", "\u6279\u91CF\u6539\u6D3E\uFF1A\u4ECE\u6587\u4EF6\u8BFB\u53D6 ID\uFF08\u6BCF\u884C\u4E00\u4E2A\uFF1B# \u5F00\u5934\u6CE8\u91CA\uFF09").option("--dry-run", "\u6279\u91CF\u6A21\u5F0F\u9884\u89C8\uFF1A\u4EC5\u89E3\u6790 owner + \u5217\u51FA ID\uFF0C\u4E0D\u8C03\u5199\u63A5\u53E3", false).option("--yes", "\u6279\u91CF\u6A21\u5F0F\u8DF3\u8FC7 N>=20 \u4E8C\u6B21\u786E\u8BA4\uFF08\u975E TTY \u5FC5\u987B\u663E\u5F0F\u4F20\uFF09", false).action(async (issueId, opts) => {
2387
+ const hasSingle = issueId !== void 0;
2388
+ const hasBatch = opts.ids !== void 0 || opts.idsFile !== void 0;
2389
+ if (hasSingle && hasBatch) {
2390
+ reportCaughtError(new Error("\u4E0D\u80FD\u540C\u65F6\u63D0\u4F9B <issue_id> \u548C --ids/--ids-file"));
2391
+ process.exit(1);
2392
+ return;
2393
+ }
2394
+ if (!hasSingle && !hasBatch) {
2395
+ reportCaughtError(new Error("\u9700\u8981\u63D0\u4F9B <issue_id> \u6216 --ids/--ids-file \u4E4B\u4E00"));
2396
+ process.exit(1);
2397
+ return;
2398
+ }
1863
2399
  try {
1864
- const hasOwner = opts.owner !== void 0;
1865
- const hasPhone = opts.ownerPhone !== void 0;
1866
- if (hasOwner === hasPhone) {
1867
- throw new Error("\u5FC5\u987B\u4E14\u4EC5\u80FD\u63D0\u4F9B --owner \u6216 --owner-phone \u5176\u4E2D\u4E00\u4E2A");
2400
+ const ownerId = await resolveOwner(String(opts.owner));
2401
+ if (hasSingle) {
2402
+ const data = await updateIssueOwner(issueId, { owner: ownerId });
2403
+ formatOutput({ success: true, data }, program2.opts().table);
2404
+ return;
1868
2405
  }
1869
- const body = {};
1870
- if (hasOwner) {
1871
- const ownerId = Number(opts.owner);
1872
- if (!Number.isInteger(ownerId) || ownerId <= 0) {
1873
- throw new Error(`--owner \u5FC5\u987B\u4E3A\u6B63\u6574\u6570\uFF0C\u6536\u5230: ${opts.owner}`);
1874
- }
1875
- body.owner = ownerId;
1876
- } else {
1877
- const phone = String(opts.ownerPhone).trim();
1878
- if (!phone) {
1879
- throw new Error("--owner-phone \u4E0D\u80FD\u4E3A\u7A7A");
1880
- }
1881
- body.owner_phone = phone;
2406
+ const ids = parseIdsInput({ ids: opts.ids, idsFile: opts.idsFile });
2407
+ if (ids.length > 200) {
2408
+ throw new Error(`\u6279\u91CF\u6539\u6D3E\u5355\u6279\u6700\u591A 200 \u6761\uFF0C\u5F53\u524D ${ids.length}\u3002\u8BF7\u62C6\u5206\u8F93\u5165`);
1882
2409
  }
1883
- const data = await updateIssueOwner(issueId, body);
1884
- formatOutput({ success: true, data }, program2.opts().table);
2410
+ if (opts.dryRun) {
2411
+ formatOutput(
2412
+ {
2413
+ success: true,
2414
+ data: {
2415
+ dry_run: true,
2416
+ total: ids.length,
2417
+ ids,
2418
+ resolved_owner_id: ownerId
2419
+ }
2420
+ },
2421
+ program2.opts().table
2422
+ );
2423
+ return;
2424
+ }
2425
+ await confirmBatch({ count: ids.length, action: "\u6539\u6D3E", yes: opts.yes });
2426
+ const result = await batchReassignOwner({ issueIds: ids, ownerUserId: ownerId });
2427
+ formatOutput({ success: true, data: result }, program2.opts().table);
1885
2428
  } catch (err) {
1886
2429
  reportCaughtError(err);
1887
2430
  process.exit(toExitCode(err));
@@ -1919,9 +2462,30 @@ function registerIssueCommand(program2) {
1919
2462
  }
1920
2463
  });
1921
2464
  }
2465
+ async function resolveSessionsForIssues(issues, workspaceId) {
2466
+ const out = [];
2467
+ for (let i = 0; i < issues.length; i++) {
2468
+ const it = issues[i];
2469
+ let conversationId;
2470
+ if (it.conversation_id) {
2471
+ conversationId = it.conversation_id;
2472
+ } else if (it.user_name) {
2473
+ try {
2474
+ conversationId = await resolveSession({ userName: it.user_name, workspaceId });
2475
+ } catch (err) {
2476
+ const msg = err instanceof Error ? err.message : String(err);
2477
+ throw new Error(`issues[${i}] \u53CD\u67E5\u4F1A\u8BDD\u5931\u8D25\uFF1A${msg}`);
2478
+ }
2479
+ } else {
2480
+ throw new Error(`issues[${i}] \u65E2\u65E0 conversation_id \u4E5F\u65E0 user_name`);
2481
+ }
2482
+ out.push({ issue: it, conversationId });
2483
+ }
2484
+ return out;
2485
+ }
1922
2486
 
1923
2487
  // src/commands/knowledge.ts
1924
- import fs5 from "fs";
2488
+ import fs7 from "fs";
1925
2489
 
1926
2490
  // src/client/knowledge-api.ts
1927
2491
  async function listExternalKnowledge(opts) {
@@ -2027,7 +2591,7 @@ async function deleteKnowledgeContent(opts) {
2027
2591
  // src/commands/knowledge.ts
2028
2592
  function readContentArg(value) {
2029
2593
  if (value.startsWith("@")) {
2030
- return fs5.readFileSync(value.slice(1), "utf-8");
2594
+ return fs7.readFileSync(value.slice(1), "utf-8");
2031
2595
  }
2032
2596
  return value;
2033
2597
  }
@@ -3448,16 +4012,16 @@ function registerSACommand(program2) {
3448
4012
  }
3449
4013
 
3450
4014
  // src/commands/testset.ts
3451
- import fs7 from "fs";
4015
+ import fs9 from "fs";
3452
4016
 
3453
4017
  // src/utils/file-output.ts
3454
- import fs6 from "fs";
4018
+ import fs8 from "fs";
3455
4019
  import path4 from "path";
3456
4020
  async function writeBinaryToFile(filePath, buffer) {
3457
4021
  try {
3458
4022
  const dir = path4.dirname(filePath);
3459
- fs6.mkdirSync(dir, { recursive: true });
3460
- fs6.writeFileSync(filePath, buffer);
4023
+ fs8.mkdirSync(dir, { recursive: true });
4024
+ fs8.writeFileSync(filePath, buffer);
3461
4025
  } catch (err) {
3462
4026
  const msg = err instanceof Error ? err.message : String(err);
3463
4027
  throw new APIError(1, `\u6587\u4EF6\u5199\u5165\u5931\u8D25: ${msg}`);
@@ -3975,7 +4539,7 @@ function readPromptInput(value) {
3975
4539
  if (!filePath) {
3976
4540
  throw new Error("File path cannot be empty after @");
3977
4541
  }
3978
- return fs7.readFileSync(filePath, "utf-8");
4542
+ return fs9.readFileSync(filePath, "utf-8");
3979
4543
  }
3980
4544
  return value;
3981
4545
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bty/customer-service-cli",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
4
4
  "description": "AI Customer Service CLI - Agent friendly",
5
5
  "type": "module",
6
6
  "main": "./dist/bin.js",
@@ -8,7 +8,8 @@
8
8
  "cs-cli": "dist/bin.js"
9
9
  },
10
10
  "files": [
11
- "/dist"
11
+ "/dist",
12
+ "CHANGELOG.md"
12
13
  ],
13
14
  "dependencies": {
14
15
  "cli-table3": "^0.6.0",