viatris-exam-cli 1.5.0 → 1.8.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
@@ -7,6 +7,8 @@
7
7
  ```bash
8
8
  npm link
9
9
  export VIATRIS_API_KEY="replace-with-real-key"
10
+ viatris-exam login
11
+ unset VIATRIS_API_KEY
10
12
  ```
11
13
 
12
14
  默认连接线上环境:
@@ -29,25 +31,27 @@ https://apaaspsc-app_17gbe211qq5-1879207268486311.aiforce.run/app/app_17gbe211qq
29
31
 
30
32
  `VIATRIS_API_ENV` 支持 `production`、`development`,以及别名 `prod`、`dev`。如需临时连接其他部署,可设置 `VIATRIS_API_BASE_URL`;该变量的优先级高于环境预设。
31
33
 
32
- CLI 不接受 `--api-key` 参数,避免密钥进入 shell history、进程列表或 Agent 日志。默认使用:
34
+ `login` 将 Key 写入 `~/.viatris-exam/.credentials.json`。隐藏目录权限为 `0700`,凭证文件权限为 `0600`。CLI 不接受 `--api-key` 参数,也不会输出 Key。
33
35
 
34
- ```text
35
- X-API-Key: <VIATRIS_API_KEY>
36
- ```
36
+ 读取顺序:
37
37
 
38
- 若服务端使用 Bearer Token:
38
+ 1. 优先读取隐藏凭证文件。
39
+ 2. 文件不存在时读取 `VIATRIS_API_KEY`。
39
40
 
40
- ```bash
41
- export VIATRIS_API_KEY_HEADER="Authorization"
42
- export VIATRIS_API_KEY_PREFIX="Bearer "
41
+ 默认认证格式:
42
+
43
+ ```text
44
+ Authorization: Bearer <API key>
43
45
  ```
44
46
 
47
+ 无需设置 `VIATRIS_API_KEY_HEADER` 或 `VIATRIS_API_KEY_PREFIX`。
48
+
45
49
  ## Agent 接口约定
46
50
 
47
51
  - API 成功响应以 JSON 写入 `stdout`。
48
52
  - 错误以单行结构化 JSON 写入 `stderr`。
49
53
  - POST/PUT 请求从文件、`stdin` 或 `--json` 读取 JSON 对象。
50
- - API Key 只从 `VIATRIS_API_KEY` 环境变量读取。
54
+ - API Key 优先从隐藏凭证文件读取,环境变量作为兜底。
51
55
  - 默认使用线上环境,可通过 `VIATRIS_API_ENV=development` 切换到开发环境。
52
56
  - `VIATRIS_API_BASE_URL` 可覆盖当前环境地址。
53
57
  - GET 请求遇到网络错误、HTTP 429 或 5xx 时自动重试,POST/PUT/PATCH/DELETE 不自动重试。
@@ -77,9 +81,10 @@ viatris-exam describe --compact
77
81
  ```bash
78
82
  viatris-exam products list
79
83
  viatris-exam products create --input product.json
84
+ viatris-exam products update "$PRODUCT_ID" --input verified-medical-data.json
80
85
  ```
81
86
 
82
- 创建产品时必须提供全局唯一的 `code`、中文名称 `name` 和英文名称 `nameEn`,并会同步初始化规范知识树根节点。创建请求不会自动重试;网络结果不确定时,应先按列表中的稳定编码确认是否已创建。
87
+ 创建产品时仅中文名称 `name` 必填;`code` 和 `nameEn` 可省略,省略 `code` 时由服务端生成。创建会同步初始化规范知识树根节点。更新命令仅用于补充经资料核验的英文名、通用名、适应症、剂型规格和备注。
83
88
 
84
89
  ### 产品知识树
85
90
 
@@ -87,9 +92,10 @@ viatris-exam products create --input product.json
87
92
  viatris-exam products get "$PRODUCT_ID"
88
93
  viatris-exam products update-knowledge-tree "$PRODUCT_ID" \
89
94
  --input knowledge-tree.json
95
+ viatris-exam products delete-knowledge-tree "$PRODUCT_ID"
90
96
  ```
91
97
 
92
- 产品主数据不属于业务知识树层级;详情查询使用产品主数据 ID,并兼容历史知识树根节点 ID。固定维度为 L1;知识树回写是完整替换操作,必须一次提交全部 15-500 个 L2/L3/L4 动态节点。先查询产品以获取固定 L1 维度的 `clientRef`。
98
+ 产品主数据不属于业务知识树层级;详情查询使用产品主数据 ID,并兼容历史知识树根节点 ID。固定维度为 L1;知识树回写是完整替换操作,必须一次提交全部 15-500 个 L2/L3/L4 动态节点。整树删除会保留产品根和固定维度,但级联删除动态节点及文档并解绑题目,只能在明确要求时执行。
93
99
 
94
100
  ### 知识节点与文档
95
101
 
@@ -100,7 +106,7 @@ viatris-exam knowledge nodes create --input node.json
100
106
  viatris-exam knowledge nodes get <node-id>
101
107
  viatris-exam knowledge nodes update <node-id> --input changes.json
102
108
  viatris-exam knowledge nodes move <node-id> --input move.json
103
- viatris-exam knowledge nodes delete <node-id>
109
+ viatris-exam knowledge nodes delete <node-id> [--recursive]
104
110
  viatris-exam knowledge documents list <node-id>
105
111
  viatris-exam knowledge documents create <node-id> --input document.json
106
112
  viatris-exam knowledge documents upsert <node-id> --input documents.json
@@ -108,10 +114,12 @@ viatris-exam knowledge documents update <document-id> --input changes.json
108
114
  viatris-exam knowledge documents delete <document-id>
109
115
  ```
110
116
 
111
- 节点删除会级联删除整棵子树及文档。产品根和固定维度受保护,不能改名、移动或删除。
117
+ 节点删除默认在存在子节点或题目绑定时返回冲突;`--recursive` 才会级联删除整棵子树及文档并解绑题目。产品根和固定维度受保护,不能改名、移动或删除。
112
118
 
113
119
  `/knowledge` CRUD 响应中的 `level` 是物理存储层级(产品根为 1、固定维度为 2);产品生成接口使用业务层级(产品隐藏、固定维度为 L1、动态节点为 L2-L4)。
114
120
 
121
+ 所有文档写入都会在请求发出前从 `title`、`url`、`summary` 中移除“小荷”“小荷医疗”和“小荷 AI 医生”及其空格变体。当前写接口只接受 `title` 和 `summary`,输入中的 `url` 会在净化后从出站载荷移除,但 URL 净化能力作为永久兼容规则保留。该规则覆盖文档创建、批量 upsert、单文档更新和整树回写中的嵌套文档;标题清洗后为空会直接返回校验错误。
122
+
115
123
  ### 出题任务
116
124
 
117
125
  ```bash
@@ -194,14 +202,14 @@ printf '%s' '{"query":"阿托伐他汀 适应症","limit":20}' |
194
202
 
195
203
  ```text
196
204
  --base-url <url> 覆盖当前环境地址
197
- --key-header <name> 覆盖 VIATRIS_API_KEY_HEADER
198
- --key-prefix <value> 覆盖 VIATRIS_API_KEY_PREFIX
205
+ --key-header <name> 覆盖认证头,默认 Authorization
206
+ --key-prefix <value> 覆盖认证前缀,默认 "Bearer "
199
207
  --timeout <ms> 超时时间,默认 30000
200
208
  --retries <n> GET 重试次数,默认 2
201
209
  --compact 输出单行紧凑 JSON
202
210
  ```
203
211
 
204
- 地址选择优先级为 `--base-url`、`VIATRIS_API_BASE_URL`、`VIATRIS_API_ENV` 对应的预设地址。认证 Key 仍必须通过 `VIATRIS_API_KEY` 提供。
212
+ 地址选择优先级为 `--base-url`、`VIATRIS_API_BASE_URL`、`VIATRIS_API_ENV` 对应的预设地址。认证 Key 优先使用隐藏凭证文件,文件不存在时使用 `VIATRIS_API_KEY`。
205
213
 
206
214
  ## 项目结构
207
215
 
@@ -215,7 +223,10 @@ src/
215
223
  ├── args.js # 参数解析与校验
216
224
  ├── input.js # JSON 文件、stdin 与内联输入
217
225
  ├── config.js # 环境、地址与认证配置
226
+ ├── credentials.js # 隐藏凭证文件读写
218
227
  ├── http.js # HTTP、重试与响应处理
228
+ ├── sanitizers/
229
+ │ └── documents.js # 文档永久净化规则
219
230
  ├── metadata.js # 版本、命令和环境元数据
220
231
  ├── help.js # 帮助文本
221
232
  ├── output.js # 标准 JSON 输出
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viatris-exam-cli",
3
- "version": "1.5.0",
3
+ "version": "1.8.0",
4
4
  "description": "Agent-friendly CLI for the Viatris exam OpenAPI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "test": "node --test",
15
- "check": "node --check src/*.js && node --check src/command-handlers/*.js",
15
+ "check": "node --check src/*.js && node --check src/command-handlers/*.js && node --check src/sanitizers/*.js",
16
16
  "check:versions": "node scripts/check-versions.js",
17
17
  "check:skill": "node scripts/check-skill.js",
18
18
  "pack:skill": "cd skills && /bin/rm -f viatris-exam-cli.zip && /usr/bin/zip -qr viatris-exam-cli.zip viatris-exam-cli -x '*.DS_Store'"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: viatris-exam-cli
3
- description: Operate the Viatris exam OpenAPI through its agent-friendly CLI. Use for products, knowledge nodes or documents, generation tasks, exam records, scoring, exams, questions, or medical search.
4
- version: 1.5.0
3
+ description: Operate the Viatris exam OpenAPI through its agent-friendly CLI. Use for login, products, knowledge, generation tasks, exam records, scoring, exams, questions, or medical search.
4
+ version: 1.8.0
5
5
  ---
6
6
 
7
7
  # Viatris Exam CLI
@@ -11,7 +11,7 @@ Use the repository-local CLI for every supported API operation. Do not call the
11
11
  ## Start
12
12
 
13
13
  1. Run commands from the repository root.
14
- 2. Require `VIATRIS_API_KEY` in the environment. Never place the key in arguments, files, output, or logs.
14
+ 2. Prefer the saved credential. To initialize or rotate it, set `VIATRIS_API_KEY` and run `node src/cli.js login`; never place the key in command arguments, output, or logs.
15
15
  3. Use production by default. Set `VIATRIS_API_ENV=development` only when the user requests development or testing.
16
16
  4. Run `node src/cli.js describe --compact` when capability discovery or exact machine-readable metadata is needed.
17
17
  5. Read [references/cli-reference.md](references/cli-reference.md) before constructing a write payload or handling a nonzero exit code.
@@ -22,12 +22,17 @@ Use the repository-local CLI for every supported API operation. Do not call the
22
22
  - Prefer `--input <file>` for existing payloads and stdin for generated payloads.
23
23
  - Parse successful `stdout` as JSON.
24
24
  - Parse failed `stderr` as JSON and branch on both exit code and `error.type`.
25
- - Use `products list` to resolve product master IDs and stable codes before product-specific workflows. `products create` also initializes the canonical knowledge-tree root.
25
+ - Authentication defaults to `Authorization: Bearer <key>`. Read the hidden credential file first and use `VIATRIS_API_KEY` only when no saved credential exists.
26
+ - Use `products list` to resolve product master IDs and stable codes before product-specific workflows. `products create` requires only a Chinese name and also initializes the canonical knowledge-tree root.
27
+ - Use `products update` only for verified optional medical master data. Do not infer English names, generic names, indications, dosage forms, or remarks without evidence.
26
28
  - Preserve `externalRef` when retrying question submission. It is the idempotency key.
27
29
  - For product-wide generation tasks, create one or more questions for every entry in `knowledgeContexts` and include its `knowledgeNodeId`.
28
30
  - Apply every returned generation rule. Product-scoped rules identify applicable product codes through `products`; global rules return an empty `products` array.
29
31
  - Prefer scoring rule V2 for generated questions and scoring results. Use stable point and deduction-rule IDs.
30
32
  - Treat product master records as outside business knowledge-tree levels. Fixed dimensions are business L1; generated nodes are L2-L4. Product detail lookup also accepts a historical knowledge-tree root ID.
33
+ - Document writes automatically remove 小荷 source-brand labels, including 小荷医疗 and whitespace variants of 小荷 AI 医生, from title, URL, and summary. Never reintroduce these labels after CLI sanitization.
34
+ - Current document write APIs accept only `title` and `summary`; the CLI drops an input `url` before sending. Keep URL sanitization in the permanent sanitizer for backward and future compatibility.
35
+ - Treat knowledge-tree and recursive node deletion as destructive. Inspect the target first and perform them only when the user explicitly requests deletion.
31
36
  - Do not automatically retry POST, PUT, PATCH, or DELETE commands. Inspect the returned error and determine whether the server accepted the request.
32
37
  - Keep temporary payloads outside the repository unless they are requested deliverables.
33
38
 
@@ -2,12 +2,21 @@
2
2
 
3
3
  ## Authentication And Environment
4
4
 
5
- Required:
5
+ Login once:
6
6
 
7
7
  ```bash
8
8
  export VIATRIS_API_KEY="..."
9
+ node src/cli.js login
10
+ unset VIATRIS_API_KEY
9
11
  ```
10
12
 
13
+ Login writes `~/.viatris-exam/.credentials.json` with file mode `0600`; its containing directory uses mode `0700`. The command never prints the key. Run login again with a new environment value to rotate the saved key.
14
+
15
+ API key lookup order:
16
+
17
+ 1. Hidden credential file.
18
+ 2. `VIATRIS_API_KEY` environment variable when the file does not exist.
19
+
11
20
  Environment selection:
12
21
 
13
22
  | Value | Target |
@@ -17,17 +26,17 @@ Environment selection:
17
26
 
18
27
  `VIATRIS_API_BASE_URL` overrides the selected environment. Use it only for an explicitly supplied custom deployment or local mock.
19
28
 
20
- Default authentication header:
29
+ Default authentication:
21
30
 
22
31
  ```text
23
- X-API-Key: <VIATRIS_API_KEY>
32
+ Authorization: Bearer <API key>
24
33
  ```
25
34
 
26
- For Bearer authentication:
35
+ No header or prefix environment variables are required. Advanced overrides remain available when connecting to a different compatible service:
27
36
 
28
37
  ```bash
29
- export VIATRIS_API_KEY_HEADER="Authorization"
30
- export VIATRIS_API_KEY_PREFIX="Bearer "
38
+ export VIATRIS_API_KEY_HEADER="Custom-Header"
39
+ export VIATRIS_API_KEY_PREFIX=""
31
40
  ```
32
41
 
33
42
  ## Capability Discovery
@@ -48,15 +57,13 @@ List product master data:
48
57
  node src/cli.js products list --compact
49
58
  ```
50
59
 
51
- The response is `{ "items": [...] }`, ordered by `sortOrder` and then name. Each record includes the stable `code`, Chinese and English names, optional generic name, indication, dosage form, `active` or `inactive` status, remark, and timestamps.
60
+ The response is `{ "items": [...] }`, ordered by `sortOrder` and then name. Each record includes the stable `code`, Chinese name, optional English name, generic name, indication, dosage form, `active` or `inactive` status, remark, and timestamps.
52
61
 
53
62
  Create a product and initialize its canonical knowledge-tree root:
54
63
 
55
64
  ```bash
56
65
  node src/cli.js products create --json '{
57
- "code": "norvasc",
58
66
  "name": "络活喜",
59
- "nameEn": "Norvasc",
60
67
  "genericName": "苯磺酸氨氯地平片",
61
68
  "indication": "高血压、冠心病",
62
69
  "dosageForm": "片剂",
@@ -65,7 +72,7 @@ node src/cli.js products create --json '{
65
72
  }'
66
73
  ```
67
74
 
68
- `code`, `name`, and `nameEn` are required. The code must contain only lowercase letters, digits, and hyphens and must be globally unique. The Chinese name must also be unique. Omitted `sortOrder` defaults to 0. This POST is not automatically retried; after an uncertain network outcome, list products and resolve by code before attempting another create.
75
+ Only `name` is required and the Chinese name must be unique. `code` is optional; when supplied it must contain only lowercase letters, digits, and hyphens and be globally unique, while omission lets the server generate it. `nameEn` and the remaining fields are optional. Omitted `sortOrder` defaults to 0. This POST is not automatically retried; after an uncertain network outcome, list products and resolve by name or code before attempting another create.
69
76
 
70
77
  Get a product and its fixed L1 dimensions:
71
78
 
@@ -73,7 +80,21 @@ Get a product and its fixed L1 dimensions:
73
80
  node src/cli.js products get "$PRODUCT_ID" --compact
74
81
  ```
75
82
 
76
- Use the product master `id`; historical knowledge-tree root IDs remain accepted for compatibility. Product master data is outside business levels L1-L4. Use each returned `fixedDimensions[].clientRef` as the parent reference for an L2 node. Each fixed dimension also returns `level: 1` and a display `codePrefix`.
83
+ Use the product master `id`; historical knowledge-tree root IDs remain accepted for compatibility. The response also contains nullable `nameEn`, `genericName`, `indication`, `dosageForm`, and `remark`. Product master data is outside business levels L1-L4. Use each returned `fixedDimensions[].clientRef` as the parent reference for an L2 node. Each fixed dimension also returns `level: 1` and a display `codePrefix`.
84
+
85
+ Update verified optional medical master data:
86
+
87
+ ```bash
88
+ node src/cli.js products update "$PRODUCT_ID" --json '{
89
+ "nameEn": "Lipitor",
90
+ "genericName": "阿托伐他汀钙片",
91
+ "indication": "高胆固醇血症、混合型血脂异常及心血管疾病预防",
92
+ "dosageForm": "片剂 10mg/20mg/40mg",
93
+ "remark": "生产企业:辉瑞制药有限公司"
94
+ }'
95
+ ```
96
+
97
+ Only supplied fields are updated. Each supplied value must be a nonempty string within the API length limit. Update these fields only from verified source material; this endpoint does not update `code`, Chinese `name`, status, or sort order.
77
98
 
78
99
  Replace a product knowledge tree:
79
100
 
@@ -114,7 +135,6 @@ Payload shape:
114
135
  "documents": [
115
136
  {
116
137
  "title": "药品说明书",
117
- "url": "https://example.com/source",
118
138
  "summary": "与该知识点直接对应的证据摘录"
119
139
  }
120
140
  ]
@@ -125,6 +145,14 @@ Payload shape:
125
145
 
126
146
  Submit the complete L2/L3/L4 node set, between 15 and 500 nodes. Every `clientRef` must be unique within the payload. L2 nodes use `dimension:<code>` as `parentClientRef`; L3/L4 nodes use their direct parent's `clientRef`. Every L4 node needs at least one document. A successful PUT replaces the previous dynamic nodes, so do not send a partial tree.
127
147
 
148
+ Delete all dynamic knowledge-tree content:
149
+
150
+ ```bash
151
+ node src/cli.js products delete-knowledge-tree "$PRODUCT_ID"
152
+ ```
153
+
154
+ This destructive operation preserves the product root and five fixed L1 dimensions, deletes all L2-L4 nodes and evidence documents, and unbinds affected questions. Inspect the product and tree first, and execute only when the user explicitly requests the full reset.
155
+
128
156
  ### Incremental Knowledge Management
129
157
 
130
158
  Get the complete physical tree and all product documents:
@@ -160,24 +188,37 @@ node src/cli.js knowledge nodes update "$NODE_ID" \
160
188
  node src/cli.js knowledge nodes move "$NODE_ID" \
161
189
  --json '{"newParentId":"NEW_PARENT_ID","sortOrder":30}'
162
190
  node src/cli.js knowledge nodes delete "$NODE_ID"
191
+ node src/cli.js knowledge nodes delete "$NODE_ID" --recursive
163
192
  ```
164
193
 
165
- Product roots and fixed dimensions are protected from rename, move, and deletion. Moving cannot cross products, target the node itself or its descendants, or produce levels deeper than L4. Deleting a node deletes its entire subtree and attached documents; inspect the node first and only delete when explicitly required.
194
+ Product roots and fixed dimensions are protected from rename, move, and deletion. Moving cannot cross products, target the node itself or its descendants, or produce levels deeper than L4. Plain deletion is the safe default and returns HTTP 409 when children or question bindings exist. `--recursive` deletes the full subtree and documents and unbinds affected questions. Inspect the node first and use recursive deletion only when explicitly requested.
166
195
 
167
196
  Manage documents:
168
197
 
169
198
  ```bash
170
199
  node src/cli.js knowledge documents list "$NODE_ID"
171
200
  node src/cli.js knowledge documents create "$NODE_ID" \
172
- --json '{"title":"说明书","url":"https://example.com/source","summary":"摘录"}'
201
+ --json '{"title":"说明书","summary":"摘录"}'
173
202
  node src/cli.js knowledge documents upsert "$NODE_ID" \
174
203
  --json '{"items":[{"title":"说明书","summary":"更新后的摘录"}]}'
175
204
  node src/cli.js knowledge documents update "$DOCUMENT_ID" \
176
- --json '{"url":null,"summary":"更新后的摘要"}'
205
+ --json '{"summary":"更新后的摘要"}'
177
206
  node src/cli.js knowledge documents delete "$DOCUMENT_ID"
178
207
  ```
179
208
 
180
- Create reuses a document with the same title under the node. Batch upsert accepts 1-20 items and updates `url` and `summary` for existing titles. A single-document update changes only supplied fields; send `null` to clear `url` or `summary`. Inspect a document before an explicitly requested delete.
209
+ Create reuses a document with the same title under the node. Batch upsert accepts 1-20 items and updates `summary` for existing titles. A single-document update changes only supplied fields; send `summary: null` to clear the summary. Current write contracts accept only `title` and `summary`; if input contains `url`, the CLI drops it before sending. Read responses may still contain historical URL data. Inspect a document before an explicitly requested delete.
210
+
211
+ ### Document Sanitization
212
+
213
+ Before every document write, the CLI removes source-brand labels from `title`, `url`, and `summary`. This applies to document create, batch upsert, single-document update, and documents nested in `products update-knowledge-tree`. URL filtering remains a permanent compatibility rule even though the current API drops URL from write payloads.
214
+
215
+ Removed forms include:
216
+
217
+ - `小荷`
218
+ - `小荷医疗`, including whitespace between `小荷` and `医疗`
219
+ - `小荷AI医生` with arbitrary whitespace, including `小荷 AI 医生`, `小荷 AI医生`, and `小荷AI 医生`
220
+
221
+ Whitespace is normalized after removal, and leading or trailing title separators are removed. If a supplied title becomes empty, the CLI returns `validation_error` without sending the request.
181
222
 
182
223
  List generation tasks:
183
224
 
Binary file
package/src/app.js CHANGED
@@ -1,6 +1,11 @@
1
- import { assertNoExtraPositionals, parseArgs } from "./args.js";
1
+ import {
2
+ assertNoExtraPositionals,
3
+ assertOptions,
4
+ parseArgs,
5
+ } from "./args.js";
2
6
  import { resolveCommand } from "./commands.js";
3
7
  import { configFrom } from "./config.js";
8
+ import { loginWithEnvironmentKey } from "./credentials.js";
4
9
  import { helpText } from "./help.js";
5
10
  import { request } from "./http.js";
6
11
  import { describeCli, VERSION } from "./metadata.js";
@@ -28,9 +33,16 @@ export async function run(
28
33
  writeJson(stdout, describeCli(), options.compact);
29
34
  return;
30
35
  }
36
+ if (positionals[0] === "login") {
37
+ assertNoExtraPositionals(positionals, 1);
38
+ assertOptions(options);
39
+ const result = await loginWithEnvironmentKey(environmentVariables);
40
+ writeJson(stdout, result, options.compact);
41
+ return;
42
+ }
31
43
 
32
44
  const command = await resolveCommand(positionals, options);
33
- const config = configFrom(options, environmentVariables);
45
+ const config = await configFrom(options, environmentVariables);
34
46
  const response = await request(command, config);
35
47
  writeJson(stdout, response, options.compact);
36
48
  }
package/src/args.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CliError } from "./errors.js";
2
2
 
3
- const BOOLEAN_OPTIONS = new Set(["help", "compact", "version"]);
3
+ const BOOLEAN_OPTIONS = new Set(["help", "compact", "version", "recursive"]);
4
4
  const GLOBAL_OPTIONS = [
5
5
  "base_url",
6
6
  "key_header",
@@ -4,10 +4,23 @@ import {
4
4
  requiredId,
5
5
  } from "../args.js";
6
6
  import { readJsonInput } from "../input.js";
7
-
8
- function jsonCommand(method, path, options) {
7
+ import {
8
+ sanitizeDocumentBatchPayload,
9
+ sanitizeDocumentPayload,
10
+ } from "../sanitizers/documents.js";
11
+
12
+ async function jsonCommand(
13
+ method,
14
+ path,
15
+ options,
16
+ transform = (body) => body,
17
+ ) {
9
18
  assertOptions(options, ["input", "json"]);
10
- return readJsonInput(options).then((body) => ({ method, path, body }));
19
+ return {
20
+ method,
21
+ path,
22
+ body: transform(await readJsonInput(options)),
23
+ };
11
24
  }
12
25
 
13
26
  function nodePath(positionals, label) {
@@ -61,9 +74,12 @@ export async function resolveKnowledge(positionals, options) {
61
74
 
62
75
  if (area === "nodes" && action === "delete") {
63
76
  assertNoExtraPositionals(positionals, 4);
64
- assertOptions(options);
77
+ assertOptions(options, ["recursive"]);
65
78
  const id = nodePath(positionals, "knowledge node ID");
66
- return { method: "DELETE", path: `/openapi/knowledge/nodes/${id}` };
79
+ return {
80
+ method: "DELETE",
81
+ path: `/openapi/knowledge/nodes/${id}${options.recursive ? "?recursive=true" : ""}`,
82
+ };
67
83
  }
68
84
 
69
85
  if (area === "nodes" && action === "move") {
@@ -93,6 +109,7 @@ export async function resolveKnowledge(positionals, options) {
93
109
  "POST",
94
110
  `/openapi/knowledge/nodes/${id}/documents`,
95
111
  options,
112
+ sanitizeDocumentPayload,
96
113
  );
97
114
  }
98
115
 
@@ -103,6 +120,7 @@ export async function resolveKnowledge(positionals, options) {
103
120
  "PUT",
104
121
  `/openapi/knowledge/nodes/${id}/documents`,
105
122
  options,
123
+ sanitizeDocumentBatchPayload,
106
124
  );
107
125
  }
108
126
 
@@ -113,6 +131,7 @@ export async function resolveKnowledge(positionals, options) {
113
131
  "PUT",
114
132
  `/openapi/knowledge/documents/${id}`,
115
133
  options,
134
+ sanitizeDocumentPayload,
116
135
  );
117
136
  }
118
137
 
@@ -4,6 +4,7 @@ import {
4
4
  requiredId,
5
5
  } from "../args.js";
6
6
  import { readJsonInput } from "../input.js";
7
+ import { sanitizeKnowledgeTreePayload } from "../sanitizers/documents.js";
7
8
 
8
9
  export async function resolveProducts(positionals, options) {
9
10
  const [resource, action] = positionals;
@@ -32,6 +33,17 @@ export async function resolveProducts(positionals, options) {
32
33
  return { method: "GET", path: `/openapi/products/${id}` };
33
34
  }
34
35
 
36
+ if (action === "update") {
37
+ assertNoExtraPositionals(positionals, 3);
38
+ assertOptions(options, ["input", "json"]);
39
+ const id = requiredId(positionals, 2, "product ID");
40
+ return {
41
+ method: "PATCH",
42
+ path: `/openapi/products/${id}`,
43
+ body: await readJsonInput(options),
44
+ };
45
+ }
46
+
35
47
  if (action === "update-knowledge-tree") {
36
48
  assertNoExtraPositionals(positionals, 3);
37
49
  assertOptions(options, ["input", "json"]);
@@ -39,7 +51,17 @@ export async function resolveProducts(positionals, options) {
39
51
  return {
40
52
  method: "PUT",
41
53
  path: `/openapi/products/${id}/knowledge-tree`,
42
- body: await readJsonInput(options),
54
+ body: sanitizeKnowledgeTreePayload(await readJsonInput(options)),
55
+ };
56
+ }
57
+
58
+ if (action === "delete-knowledge-tree") {
59
+ assertNoExtraPositionals(positionals, 3);
60
+ assertOptions(options);
61
+ const id = requiredId(positionals, 2, "product ID");
62
+ return {
63
+ method: "DELETE",
64
+ path: `/openapi/products/${id}/knowledge-tree`,
43
65
  };
44
66
  }
45
67
 
package/src/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { integerOption } from "./args.js";
2
+ import { readStoredApiKey } from "./credentials.js";
2
3
  import { CliError } from "./errors.js";
3
4
  import {
4
5
  API_ENVIRONMENT_ALIASES,
@@ -7,7 +8,10 @@ import {
7
8
  DEFAULT_TIMEOUT_MS,
8
9
  } from "./metadata.js";
9
10
 
10
- export function configFrom(options, environmentVariables = process.env) {
11
+ export async function configFrom(
12
+ options,
13
+ environmentVariables = process.env,
14
+ ) {
11
15
  const baseUrlOverride =
12
16
  options.base_url ?? environmentVariables.VIATRIS_API_BASE_URL;
13
17
  let environment = null;
@@ -28,13 +32,16 @@ export function configFrom(options, environmentVariables = process.env) {
28
32
  baseUrl = API_ENVIRONMENTS[environment];
29
33
  }
30
34
 
31
- const apiKey = environmentVariables.VIATRIS_API_KEY;
35
+ const storedApiKey = await readStoredApiKey(environmentVariables);
36
+ const apiKey = storedApiKey ?? environmentVariables.VIATRIS_API_KEY;
32
37
  const keyHeader =
33
38
  options.key_header ??
34
39
  environmentVariables.VIATRIS_API_KEY_HEADER ??
35
- "X-API-Key";
40
+ "Authorization";
36
41
  const keyPrefix =
37
- options.key_prefix ?? environmentVariables.VIATRIS_API_KEY_PREFIX ?? "";
42
+ options.key_prefix ??
43
+ environmentVariables.VIATRIS_API_KEY_PREFIX ??
44
+ "Bearer ";
38
45
  const timeoutMs = integerOption(
39
46
  options.timeout ?? String(DEFAULT_TIMEOUT_MS),
40
47
  "timeout",
@@ -63,9 +70,11 @@ export function configFrom(options, environmentVariables = process.env) {
63
70
  );
64
71
  }
65
72
  if (!apiKey) {
66
- throw new CliError("config_error", "VIATRIS_API_KEY is required.", {
67
- exitCode: 3,
68
- });
73
+ throw new CliError(
74
+ "config_error",
75
+ "API key not found. Run `viatris-exam login` with VIATRIS_API_KEY set, or set VIATRIS_API_KEY for this request.",
76
+ { exitCode: 3 },
77
+ );
69
78
  }
70
79
  if (!/^[A-Za-z0-9-]+$/.test(keyHeader)) {
71
80
  throw new CliError("config_error", "API key header name is invalid.", {
@@ -77,6 +86,7 @@ export function configFrom(options, environmentVariables = process.env) {
77
86
  baseUrl: baseUrl.replace(/\/+$/, ""),
78
87
  environment,
79
88
  apiKey,
89
+ apiKeySource: storedApiKey === null ? "environment" : "credential_file",
80
90
  keyHeader,
81
91
  keyPrefix,
82
92
  timeoutMs,
@@ -0,0 +1,100 @@
1
+ import {
2
+ chmod,
3
+ mkdir,
4
+ readFile,
5
+ rename,
6
+ rm,
7
+ writeFile,
8
+ } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { CliError } from "./errors.js";
12
+
13
+ export const CREDENTIAL_FILE_DISPLAY =
14
+ "~/.viatris-exam/.credentials.json";
15
+
16
+ export function credentialFilePath(environmentVariables = process.env) {
17
+ const home = environmentVariables.HOME || homedir();
18
+ return join(home, ".viatris-exam", ".credentials.json");
19
+ }
20
+
21
+ export async function readStoredApiKey(
22
+ environmentVariables = process.env,
23
+ ) {
24
+ const path = credentialFilePath(environmentVariables);
25
+ let content;
26
+ try {
27
+ content = await readFile(path, "utf8");
28
+ } catch (error) {
29
+ if (error.code === "ENOENT") return null;
30
+ throw new CliError(
31
+ "config_error",
32
+ `Unable to read the credential file: ${error.message}`,
33
+ { exitCode: 3, details: { credentialFile: path } },
34
+ );
35
+ }
36
+
37
+ try {
38
+ const credentials = JSON.parse(content);
39
+ if (
40
+ credentials === null ||
41
+ typeof credentials !== "object" ||
42
+ typeof credentials.apiKey !== "string" ||
43
+ !credentials.apiKey
44
+ ) {
45
+ throw new Error("apiKey is missing or invalid");
46
+ }
47
+ return credentials.apiKey;
48
+ } catch (error) {
49
+ throw new CliError(
50
+ "config_error",
51
+ `Invalid credential file: ${error.message}`,
52
+ { exitCode: 3, details: { credentialFile: path } },
53
+ );
54
+ }
55
+ }
56
+
57
+ export async function loginWithEnvironmentKey(
58
+ environmentVariables = process.env,
59
+ ) {
60
+ const apiKey = environmentVariables.VIATRIS_API_KEY;
61
+ if (!apiKey) {
62
+ throw new CliError(
63
+ "config_error",
64
+ "VIATRIS_API_KEY is required for login.",
65
+ { exitCode: 3 },
66
+ );
67
+ }
68
+
69
+ const path = credentialFilePath(environmentVariables);
70
+ const directory = dirname(path);
71
+ const temporaryPath = join(
72
+ directory,
73
+ `.credentials.${process.pid}.${Date.now()}.tmp`,
74
+ );
75
+
76
+ try {
77
+ await mkdir(directory, { recursive: true, mode: 0o700 });
78
+ await chmod(directory, 0o700);
79
+ await writeFile(
80
+ temporaryPath,
81
+ `${JSON.stringify({ version: 1, apiKey }, null, 2)}\n`,
82
+ { encoding: "utf8", mode: 0o600 },
83
+ );
84
+ await rename(temporaryPath, path);
85
+ await chmod(path, 0o600);
86
+ } catch (error) {
87
+ await rm(temporaryPath, { force: true });
88
+ throw new CliError(
89
+ "config_error",
90
+ `Unable to save the credential file: ${error.message}`,
91
+ { exitCode: 3, details: { credentialFile: path } },
92
+ );
93
+ }
94
+
95
+ return {
96
+ status: "saved",
97
+ credentialFile: path,
98
+ permissions: "0600",
99
+ };
100
+ }
package/src/help.js CHANGED
@@ -5,19 +5,23 @@ export function helpText() {
5
5
 
6
6
  Usage:
7
7
  viatris-exam <resource> <action> [id|query] [options]
8
+ viatris-exam login
8
9
  viatris-exam describe
9
10
 
10
11
  Commands:
12
+ login
11
13
  products list
12
14
  products create --input <file|->
13
15
  products get <product-id>
16
+ products update <product-id> --input <file|->
14
17
  products update-knowledge-tree <product-id> --input <file|->
18
+ products delete-knowledge-tree <product-id>
15
19
  knowledge tree <product-code>
16
20
  knowledge nodes by-path --input <file|->
17
21
  knowledge nodes create --input <file|->
18
22
  knowledge nodes get <node-id>
19
23
  knowledge nodes update <node-id> --input <file|->
20
- knowledge nodes delete <node-id>
24
+ knowledge nodes delete <node-id> [--recursive]
21
25
  knowledge nodes move <node-id> --input <file|->
22
26
  knowledge documents list <node-id>
23
27
  knowledge documents create <node-id> --input <file|->
@@ -46,8 +50,8 @@ Medical search:
46
50
 
47
51
  Global options:
48
52
  --base-url <url> Override the selected environment URL.
49
- --key-header <name> Override VIATRIS_API_KEY_HEADER (default: X-API-Key).
50
- --key-prefix <value> Override VIATRIS_API_KEY_PREFIX (default: empty).
53
+ --key-header <name> Override the auth header (default: Authorization).
54
+ --key-prefix <value> Override the auth prefix (default: "Bearer ").
51
55
  --timeout <ms> Request timeout (default: 30000).
52
56
  --retries <n> GET retries for 429/5xx/network errors (default: 2).
53
57
  --compact Emit compact JSON.
@@ -57,9 +61,12 @@ Global options:
57
61
  Environment:
58
62
  VIATRIS_API_ENV API environment: production (default) or development.
59
63
  VIATRIS_API_BASE_URL Optional URL override with highest priority.
60
- VIATRIS_API_KEY Required API key. It cannot be passed as a CLI argument.
64
+ VIATRIS_API_KEY Login input or fallback key when no saved key exists.
61
65
  VIATRIS_API_KEY_HEADER Optional authentication header name.
62
- VIATRIS_API_KEY_PREFIX Optional header value prefix, for example "Bearer ".
66
+ VIATRIS_API_KEY_PREFIX Optional authentication header value prefix.
67
+
68
+ Credentials:
69
+ ~/.viatris-exam/.credentials.json (read before VIATRIS_API_KEY, mode 0600)
63
70
 
64
71
  Agent contract:
65
72
  Successful API JSON is written to stdout. Structured errors are written to
package/src/metadata.js CHANGED
@@ -1,4 +1,7 @@
1
- export const VERSION = "1.5.0";
1
+ import { CREDENTIAL_FILE_DISPLAY } from "./credentials.js";
2
+ import { DOCUMENT_SANITIZATION_POLICY } from "./sanitizers/documents.js";
3
+
4
+ export const VERSION = "1.8.0";
2
5
  export const DEFAULT_TIMEOUT_MS = 30_000;
3
6
  export const DEFAULT_RETRIES = 2;
4
7
 
@@ -15,6 +18,12 @@ export const API_ENVIRONMENT_ALIASES = Object.freeze({
15
18
  });
16
19
 
17
20
  export const COMMANDS = Object.freeze([
21
+ {
22
+ command: "login",
23
+ local: true,
24
+ input: "VIATRIS_API_KEY environment variable",
25
+ summary: "Save the API key to the local hidden credential file.",
26
+ },
18
27
  {
19
28
  command: "products list",
20
29
  method: "GET",
@@ -25,7 +34,7 @@ export const COMMANDS = Object.freeze([
25
34
  command: "products create",
26
35
  method: "POST",
27
36
  path: "/openapi/products",
28
- input: "{code, name, nameEn, genericName?, indication?, dosageForm?, sortOrder?, remark?}",
37
+ input: "{name, code?, nameEn?, genericName?, indication?, dosageForm?, sortOrder?, remark?}",
29
38
  summary: "Create a product and initialize its canonical knowledge-tree root.",
30
39
  },
31
40
  {
@@ -34,6 +43,13 @@ export const COMMANDS = Object.freeze([
34
43
  path: "/openapi/products/{id}",
35
44
  summary: "Get a product, knowledge-tree status, and fixed dimensions.",
36
45
  },
46
+ {
47
+ command: "products update <product-id>",
48
+ method: "PATCH",
49
+ path: "/openapi/products/{id}",
50
+ input: "{nameEn?, genericName?, indication?, dosageForm?, remark?}",
51
+ summary: "Update verified optional medical product master data.",
52
+ },
37
53
  {
38
54
  command: "products update-knowledge-tree <product-id>",
39
55
  method: "PUT",
@@ -41,6 +57,12 @@ export const COMMANDS = Object.freeze([
41
57
  input: "{nodes: [...]}",
42
58
  summary: "Replace the product's dynamic L2/L3/L4 knowledge-tree nodes.",
43
59
  },
60
+ {
61
+ command: "products delete-knowledge-tree <product-id>",
62
+ method: "DELETE",
63
+ path: "/openapi/products/{id}/knowledge-tree",
64
+ summary: "Delete all dynamic product knowledge-tree nodes and documents.",
65
+ },
44
66
  {
45
67
  command: "knowledge tree <product-code>",
46
68
  method: "GET",
@@ -78,7 +100,10 @@ export const COMMANDS = Object.freeze([
78
100
  command: "knowledge nodes delete <node-id>",
79
101
  method: "DELETE",
80
102
  path: "/openapi/knowledge/nodes/{id}",
81
- summary: "Delete a knowledge node, its subtree, and documents.",
103
+ options: {
104
+ "--recursive": "delete descendants, documents, and node bindings",
105
+ },
106
+ summary: "Delete a node, optionally recursing through its subtree.",
82
107
  },
83
108
  {
84
109
  command: "knowledge nodes move <node-id>",
@@ -97,7 +122,7 @@ export const COMMANDS = Object.freeze([
97
122
  command: "knowledge documents create <node-id>",
98
123
  method: "POST",
99
124
  path: "/openapi/knowledge/nodes/{id}/documents",
100
- input: "{title, url?, summary?}",
125
+ input: "{title, summary?}",
101
126
  summary: "Idempotently create a document under a node.",
102
127
  },
103
128
  {
@@ -111,7 +136,7 @@ export const COMMANDS = Object.freeze([
111
136
  command: "knowledge documents update <document-id>",
112
137
  method: "PUT",
113
138
  path: "/openapi/knowledge/documents/{id}",
114
- input: "{title?, url?, summary?}",
139
+ input: "{title?, summary?}",
115
140
  summary: "Update supplied fields on a knowledge document.",
116
141
  },
117
142
  {
@@ -219,7 +244,10 @@ export function describeCli() {
219
244
  authentication: {
220
245
  keyEnvironmentVariable: "VIATRIS_API_KEY",
221
246
  baseUrlEnvironmentVariable: "VIATRIS_API_BASE_URL",
222
- defaultHeader: "X-API-Key",
247
+ defaultHeader: "Authorization",
248
+ defaultPrefix: "Bearer ",
249
+ credentialFile: CREDENTIAL_FILE_DISPLAY,
250
+ keySourcePriority: ["credential_file", "environment"],
223
251
  },
224
252
  environments: {
225
253
  environmentVariable: "VIATRIS_API_ENV",
@@ -228,6 +256,7 @@ export function describeCli() {
228
256
  urls: API_ENVIRONMENTS,
229
257
  baseUrlOverrideEnvironmentVariable: "VIATRIS_API_BASE_URL",
230
258
  },
259
+ documentSanitization: DOCUMENT_SANITIZATION_POLICY,
231
260
  commands: COMMANDS,
232
261
  exitCodes: EXIT_CODES,
233
262
  };
@@ -0,0 +1,113 @@
1
+ import { CliError } from "../errors.js";
2
+
3
+ export const DOCUMENT_SANITIZATION_POLICY = Object.freeze({
4
+ fields: ["title", "url", "summary"],
5
+ writableFields: ["title", "summary"],
6
+ droppedWriteFields: ["url"],
7
+ removedLabels: [
8
+ "小荷",
9
+ "小荷医疗",
10
+ "小荷AI医生(AI 与医生之间允许任意空格)",
11
+ ],
12
+ appliesTo: [
13
+ "products update-knowledge-tree",
14
+ "knowledge documents create",
15
+ "knowledge documents upsert",
16
+ "knowledge documents update",
17
+ ],
18
+ emptyTitle: "validation_error",
19
+ });
20
+
21
+ const PROHIBITED_LABEL = /小荷\s*(?:AI\s*医生|医疗)?/giu;
22
+ const TITLE_EDGE_SEPARATOR = /^[\s\-—–_::||·]+|[\s\-—–_::||·]+$/gu;
23
+
24
+ function sanitizeText(value) {
25
+ return value
26
+ .replace(PROHIBITED_LABEL, "")
27
+ .replace(/[ \t]{2,}/g, " ")
28
+ .trim();
29
+ }
30
+
31
+ function sanitizeTitle(value) {
32
+ return sanitizeText(value).replace(TITLE_EDGE_SEPARATOR, "").trim();
33
+ }
34
+
35
+ export function sanitizeDocument(document, location = "document") {
36
+ if (
37
+ document === null ||
38
+ Array.isArray(document) ||
39
+ typeof document !== "object"
40
+ ) {
41
+ return document;
42
+ }
43
+
44
+ const sanitized = { ...document };
45
+ if (typeof sanitized.title === "string") {
46
+ sanitized.title = sanitizeTitle(sanitized.title);
47
+ if (!sanitized.title) {
48
+ throw new CliError(
49
+ "validation_error",
50
+ `${location}.title is empty after prohibited source labels are removed.`,
51
+ );
52
+ }
53
+ }
54
+ for (const field of ["url", "summary"]) {
55
+ if (typeof sanitized[field] === "string") {
56
+ sanitized[field] = sanitizeText(sanitized[field]);
57
+ }
58
+ }
59
+ return sanitized;
60
+ }
61
+
62
+ function prepareDocumentForWrite(document, location) {
63
+ const sanitized = sanitizeDocument(document, location);
64
+ if (
65
+ sanitized === null ||
66
+ Array.isArray(sanitized) ||
67
+ typeof sanitized !== "object"
68
+ ) {
69
+ return sanitized;
70
+ }
71
+ const { url: _unsupportedUrl, ...writableDocument } = sanitized;
72
+ return writableDocument;
73
+ }
74
+
75
+ export function sanitizeDocumentPayload(body) {
76
+ return prepareDocumentForWrite(body, "document");
77
+ }
78
+
79
+ export function sanitizeDocumentBatchPayload(body) {
80
+ if (!Array.isArray(body.items)) return body;
81
+ return {
82
+ ...body,
83
+ items: body.items.map((document, index) =>
84
+ prepareDocumentForWrite(document, `items[${index}]`),
85
+ ),
86
+ };
87
+ }
88
+
89
+ export function sanitizeKnowledgeTreePayload(body) {
90
+ if (!Array.isArray(body.nodes)) return body;
91
+ return {
92
+ ...body,
93
+ nodes: body.nodes.map((node, nodeIndex) => {
94
+ if (
95
+ node === null ||
96
+ Array.isArray(node) ||
97
+ typeof node !== "object" ||
98
+ !Array.isArray(node.documents)
99
+ ) {
100
+ return node;
101
+ }
102
+ return {
103
+ ...node,
104
+ documents: node.documents.map((document, documentIndex) =>
105
+ prepareDocumentForWrite(
106
+ document,
107
+ `nodes[${nodeIndex}].documents[${documentIndex}]`,
108
+ ),
109
+ ),
110
+ };
111
+ }),
112
+ };
113
+ }