Compare commits

...

1 Commits

Author SHA1 Message Date
zhaoyukun.yk
d4472a1d6b fix(skills): align skill guidance with the typed error contract
Skill references written before the typed-error refactor still taught retired envelope shapes. AI agents following them now read what the CLI actually emits:

- permission recovery reads error.missing_scopes instead of the upstream permission_violations detail
- confirmation gates use type=confirmation, subtype=confirmation_required, and flat risk/action fields
- drive duplicate-remote failures are typed validation envelopes (failed_precondition with params[]), not duplicate_remote_path with error.detail
- drive batch partial failures are ok:false results on stdout, not an error.type=partial_failure stderr envelope
- minutes edit-permission and word-replace misses branch on stable error.code/error.subtype, not message text
- slides replace failures are stderr typed envelopes only; no raw backend response is printed to stdout
- slides and Base command outputs show the ok/identity/data success envelope instead of the raw {code,msg} OpenAPI wrapper
2026-07-07 21:45:37 +08:00
20 changed files with 130 additions and 85 deletions

View File

@@ -76,4 +76,4 @@ CLI 提供三种互斥的 scope 表达方式:
## 不在本 skill 范围
- OpenAPI spec 全量导出、实时日志 tail、Webhook 消费、多鉴权方式:本期不支持。
- 身份选择、权限不足处理(`permission_violations`→`console_url`、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。
- 身份选择、权限不足处理(`missing_scopes`→`console_url`、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。

View File

@@ -85,7 +85,7 @@ metadata:
## 身份与权限降级
- 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`
- user 身份报 scope/授权不足,或错误中包含 `permission_violations` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
- user 身份报 scope/授权不足,或错误中包含 `missing_scopes` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
- user 身份报资源级无访问且无授权恢复提示时,才可用 `--as bot` 重试一次bot 仍失败就停止重试并按权限错误处理。
- `91403` 或明确不可访问错误不要循环换身份重试。
- `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。

View File

@@ -98,16 +98,35 @@ lark-cli base +dashboard-block-get \
## 返回结构总览
服务端响应外层仍然是标准 OpenAPI 包装
CLI 输出标准成功信封(判断成功用 `ok == true` 或退出码 0不要找 `code == 0`
```json
{
"code": 0,
"msg": "success",
"ok": true,
"identity": "user",
"data": {
"dimensions": [...],
"measures": [...],
"main_data": [...]
"dimensions": [
{
"field_name": "地区",
"alias": "dim_region"
}
],
"measures": [
{
"field_name": "销售额",
"alias": "me_sales"
}
],
"main_data": [
{
"dim_region": {
"value": "华东"
},
"me_sales": {
"value": 12345
}
}
]
}
}
```

View File

@@ -347,28 +347,51 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|------|------|------|------|
| `format` | string | 是 | 固定为 `"flat"`,表示返回扁平化的对象数组 |
## API 出参详情
## CLI 出参详情
**成功时**
**成功时**stdout判断成功用 `ok == true` 或退出码 0
```json
{"code": 0, "data": {"main_data": [{"dim_city": {"value": "北京"}, "total_amount": {"value": 12345.00}}, ...]}, "msg": ""}
{
"ok": true,
"identity": "user",
"data": {
"main_data": [
{
"dim_city": {
"value": "北京"
},
"total_amount": {
"value": 12345.00
}
},
{
"dim_city": {
"value": "上海"
},
"total_amount": {
"value": 6789.00
}
}
]
}
}
```
**失败时**
**失败时**stderr 类型化错误信封,非零退出码;`error.code` 是上游 API 错误码):
```json
{"code": 800004006, "data": {"error": {"code": 800004006, ...}}, "msg": "DSL validation failed"}
{"ok": false, "identity": "user", "error": {"type": "api", "subtype": "...", "code": 800004006, "message": "DSL validation failed", "hint": "..."}}
```
**Response 字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | int | 状态码0 为成功 |
| `msg` | string | 错误信息 |
| `ok` | bool | 是否成功 |
| `data.main_data` | []object | 查询结果数组,每个元素为一行数据 |
| `data.error` | object | 失败时的错误详情 |
| `error.code` | int | 失败时的上游 API 错误码 |
| `error.message` / `error.hint` | string | 失败原因与建议的恢复动作 |
每行数据的字段值封装在 CellValue 中:
@@ -387,7 +410,7 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
## 返回值
命令成功后输出 `data` 字段的内容
命令成功后,成功信封的 `data` 字段即查询结果
```json
{

View File

@@ -54,7 +54,7 @@ lark-cli drive +member-add \
}
```
批量部分失败时,`partial``true`CLI 以非零退出码返回 `error.type=partial_failure`。检查 `error.detail` 中的 `requested_count``succeeded_count``members``missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
批量部分失败时,`partial``true`同一份结果以 `ok:false` 部分失败信封写到 **stdout**stderr 不再输出单独的错误信封CLI 以非零退出码结束。检查 `data` 中的 `requested_count``succeeded_count``members``missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
## 行为说明

View File

@@ -17,11 +17,11 @@
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error` |
`summary.failed > 0` 时命令以 **非零状态码**`exit=1``error.type=partial_failure`)退出,且同一份 `summary + items` `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`
`summary.failed > 0` 时命令以 **非零状态码**`exit=1`)退出同一份 `summary + items` `ok:false` 部分失败信封写到 **stdout**(字段在 `data.summary` / `data.items`,另附 `data.note` 说明失败情况stderr 不再输出单独的错误信封;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(stderr 类型化错误信封:`error.type=validation``error.subtype=failed_precondition``error.params[]` 逐条列出冲突的 `rel_path` 及碰撞条目),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
| 策略 | 行为 |
|------|------|
@@ -80,7 +80,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
- `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。
- `--delete-local --yes`**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。
- `--delete-local --yes`**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
- `--delete-local --yes`**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `ok:false` 部分失败结果非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
- 远端同名文件冲突且使用默认 `fail` → 在下载阶段前失败,删除阶段不会运行。
- 不传 `--delete-local``summary.deleted_local` 永远是 0命令对本地"多余"文件视而不见。

View File

@@ -24,7 +24,7 @@
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(stderr 类型化错误信封:`error.type=validation``error.subtype=failed_precondition``error.params[]` 逐条列出冲突的 `rel_path` 及碰撞条目),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
| 策略 | 行为 |
|------|------|

View File

@@ -19,7 +19,7 @@
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path``+status` 会在下载/hash 前直接失败,返回 `error.type=duplicate_remote_path`,并在 `error.detail.duplicates_remote[]` 中列出该路径下所有冲突条目的 `file_token``type`、名称、大小和时间字段;其中 `created_time``modified_time` 缺失时会省略,`size` 在缺失或为 `0` 时都可能被省略。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。
如果 Drive 中多个条目映射到同一个 `rel_path``+status` 会在下载/hash 前直接失败,在 stderr 返回类型化错误信封(`error.type=validation``error.subtype=failed_precondition``error.params[]` 每条的 `name` 是冲突的 `rel_path``reason` 枚举该路径下所有碰撞条目(`type` + `file_token`。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略`error.hint` 也给出了同样的恢复选项)
## 命令
@@ -76,20 +76,18 @@ lark-cli drive +status \
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "duplicate_remote_path",
"message": "multiple Drive entries map to the same rel_path",
"detail": {
"duplicates_remote": [
{
"rel_path": "dup.txt",
"entries": [
{"file_token": "<full_file_token>", "type": "file", "name": "dup.txt", "size": 5, "created_time": "1730000000", "modified_time": "1730000000"},
{"file_token": "<folder_token>", "type": "folder", "name": "dup.txt", "created_time": "1730000060", "modified_time": "1730000060"}
]
}
]
}
"type": "validation",
"subtype": "failed_precondition",
"message": "1 rel_path(s) map to multiple Drive entries",
"hint": "resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help",
"params": [
{
"name": "dup.txt",
"reason": "2 Drive entries collide here: file <full_file_token>, folder <folder_token>"
}
]
}
}
```

View File

@@ -120,9 +120,9 @@ lark-cli minutes +todo --minute-token <token> --as user --todos '[
**更新 / 删除前**:先用 `minutes +detail --minute-tokens <token> --todo` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
**无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`
**无编辑权限**:若 CLI 返回稳定字段 `error.subtype=permission_denied`,且 `error.code``40005``+todo` / `+word-replace` 等编辑接口)或 `2091005`(如标题更新),表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope``error.message` 只作人读说明,不要用它做分支判断。
**逐字稿关键词替换无命中**`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
**逐字稿关键词替换无命中**`minutes +word-replace` 时,若 CLI 返回稳定字段 `error.code=40001``error.subtype=not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。`error.message` 只作人读说明,不要用它做分支判断。
**替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。

View File

@@ -126,8 +126,8 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
| 未指定操作 | 单条模式传 `--operation`,或批量传 `--todos` |
| `--todos` 与单条 flags 冲突 | 二选一 |
| `todos[i]` 校验失败 | 检查该条 `operation` 与字段组合 |
| `error.type` = `no_edit_permission` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope` |
| 缺少 OAuth scope`permission_violations``minutes:minutes:update` | `lark-cli auth login --scope "minutes:minutes:update"` |
| `error.code=40005` `error.subtype=permission_denied` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope``error.message` 只作人读说明 |
| 缺少 OAuth scope`error.missing_scopes``minutes:minutes:update` | `lark-cli auth login --scope "minutes:minutes:update"` |
## 参考

View File

@@ -69,7 +69,7 @@ LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 lark-cli a
遇到权限相关错误时,**根据当前身份类型采取不同解决方案**。
错误响应中包含关键信息:
- `permission_violations`:列出缺失的 scope (N选1)
- `missing_scopes`:列出缺失的 scope (N选1)
- `console_url`:飞书开发者后台的权限配置链接
- `hint`:建议的修复命令
@@ -178,22 +178,22 @@ lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门
```json
{
"ok": false,
"identity": "bot",
"error": {
"type": "confirmation_required",
"type": "confirmation",
"subtype": "confirmation_required",
"message": "drive +delete requires confirmation",
"hint": "add --yes to confirm",
"risk": {
"level": "high-risk-write",
"action": "drive +delete"
}
"risk": "high-risk-write",
"action": "drive +delete"
}
}
```
**遇到这种情况,不要当普通错误放弃。** 按以下流程处理:
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation_required"`
2. **向用户确认**:把 `error.risk.action` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation"``error.subtype == "confirmation_required"`
2. **向用户确认**:把 `error.action``error.risk` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
3. **用户同意** → 在你**原始 argv 的末尾追加 `--yes`** 后重试
4. **用户拒绝** → 终止流程,不要擅自改写参数或跳过门禁

View File

@@ -65,15 +65,15 @@ lark-cli slides xml_presentations get --as user --params '{
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"xml_presentation": {
"presentation_id": "slides_example_presentation_id",
"revision_id": 3,
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
}
},
"msg": "success"
}
}
```
@@ -94,12 +94,12 @@ lark-cli slides xml_presentation.slide create --as user --params '{
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"slide_id": "slide_example_id",
"revision_id": 100
},
"msg": "success"
}
}
```
@@ -116,11 +116,11 @@ lark-cli slides xml_presentation.slide delete --as user --params '{
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"revision_id": 101
},
"msg": "success"
}
}
```

View File

@@ -66,7 +66,8 @@ lark-cli slides +screenshot --as user \
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"xml_presentation_id": "slides_example_presentation_id",
"output_dir": ".lark-slides/screenshots",
@@ -79,8 +80,7 @@ lark-cli slides +screenshot --as user \
"size": 12345
}
]
},
"msg": "success"
}
}
```

View File

@@ -141,12 +141,12 @@ lark-cli slides xml_presentation.slide create --as user \
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"slide_id": "slide_example_id",
"revision_id": 100
},
"msg": "success"
}
}
```

View File

@@ -61,11 +61,11 @@ lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presenta
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"revision_id": 100
},
"msg": "success"
}
}
```

View File

@@ -65,15 +65,15 @@ lark-cli slides xml_presentation.slide get --as user --params '{
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"slide": {
"slide_id": "slide_example_id",
"content": "<slide id=\"slide_example_id\"><style/><data>...</data></slide>"
},
"revision_id": 100
},
"msg": "success"
}
}
```

View File

@@ -130,24 +130,28 @@ lark-cli slides xml_presentation.slide replace --as user --params '{
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"revision_id": 105
},
"msg": "success"
}
}
```
### 失败(任一 part 失败,整批不生效)
失败时返回非零错误码(如 3350001。若后端能定位失败的 part`data` 中可能附带:
失败时命令以非零退出码结束stderr 返回类型化错误信封(`error.type` / `error.subtype` / `error.code`(如 3350001/ `error.message` / `error.hint`)。这个普通写命令的失败路径不会在 stdout 额外打印后端原始响应;脚本和 agent 应以退出码与 stderr 信封为准。
```json
{
"code": 3350001,
"data": {
"failed_part_index": 0,
"failed_reason": "block not found"
"ok": false,
"identity": "user",
"error": {
"type": "api",
"subtype": "...",
"code": 3350001,
"message": "...",
"hint": "..."
}
}
```
@@ -155,8 +159,9 @@ lark-cli slides xml_presentation.slide replace --as user --params '{
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.revision_id` | integer | 成功时返回更新后最新版本号 |
| `data.failed_part_index` | integer | 失败的 part 在 `parts` 数组中的索引(从 0 起) |
| `data.failed_reason` | string | 失败原因 |
| `error.code` | integer | 失败时的上游 API 错误码 |
| `error.subtype` | string | 类型化错误子类,脚本可与 `error.code` 一起判断恢复动作 |
| `error.hint` | string | 建议的恢复动作 |
## 常见错误

View File

@@ -56,15 +56,15 @@ lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id"
```json
{
"code": 0,
"ok": true,
"identity": "user",
"data": {
"xml_presentation": {
"presentation_id": "slides_example_presentation_id",
"revision_id": 1,
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
}
},
"msg": "success"
}
}
```

View File

@@ -18,7 +18,7 @@ metadata:
## 内测提示
- 当前功能正在内测中,仅少数用户可用。忽略 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 中的权限申请流程,以下方的指引为准。
- 如果 `lark-cli` 提示 `missing required scope(s)` / `permission_violations`,不要走普通权限申请流程;先提示用户加入早鸟群确认内测权限已开通,再按“应用身份权限配置检查”处理应用权限、安装和数据范围。
- 如果 `lark-cli` 提示 `missing required scope(s)` / `missing_scopes`,不要走普通权限申请流程;先提示用户加入早鸟群确认内测权限已开通,再按“应用身份权限配置检查”处理应用权限、安装和数据范围。
- 如果 `lark-cli` 返回 `error.code=20017` / `ErrNotInGray`,提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`
## 定位
@@ -167,7 +167,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
## 应用身份权限配置检查
应用身份 `--as bot``no permission``missing required scope(s)``permission_violations``ErrNotInGray``20017` 时,不要引导用户执行 `auth login`。按顺序检查:
应用身份 `--as bot``no permission``missing required scope(s)``missing_scopes``ErrNotInGray``20017` 时,不要引导用户执行 `auth login`。按顺序检查:
1. 以 CLI 返回的 metadata / error envelope 为准,确认提示的 VC Agent 相关权限已开通。常见读取 active meeting / events 需要会中事件读取权限;应用机器人入会 / 离会需要 bot 入会写权限。
2. 应用已发布并安装到当前租户。

View File

@@ -9,7 +9,7 @@
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with a typed validation error for the duplicate rel_path, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.