Compare commits

..

26 Commits

Author SHA1 Message Date
liangshuo-1
14a3213038 chore(release): v1.0.32 (#918)
Change-Id: I3d1a8ec4faf1ce585fb9eae45287bf02586e3e90
2026-05-15 20:55:43 +08:00
mazhe-nerd
caff780c17 feat(config): lark-channel secret supports SecretInput protocol (#912) 2026-05-15 20:53:59 +08:00
fangshuyu-768
5778adfefa fix(drive): preserve parent token on nested overwrite (#908)
* fix(drive): preserve parent token on nested overwrite

Ensure drive +push overwrite requests for nested files keep parent_node aligned with the actual remote parent folder and report parent resolution failures explicitly.

* test(drive): cover nested overwrite push workflow

Add a live drive +push workflow case for overwriting a nested remote file so the PR parent-token fix is exercised against the real backend and verified to converge via +status.
2026-05-15 18:32:58 +08:00
songyoung77
7400226e34 feat(doc): add --width/--height flags to docs +media-insert (#832)
* feat(doc): add width/height params to buildBatchUpdateData

Extend buildBatchUpdateData signature with width and height int params.
When mediaType is "image" and either dimension is positive, the value is
included in the replace_image payload. Existing call sites pass 0, 0.

* feat(doc): add --width/--height flags with validation to docs +media-insert

* feat(doc): add aspect-ratio auto-calculation helpers

Add computeMissingDimension (pure ratio math) and detectImageDimensions
(header-only image.DecodeConfig) with PNG/JPEG/GIF blank-import decoders,
plus imageDimensions struct; drive with two new TDD tests.

* feat(doc): wire --width/--height into Execute with aspect-ratio calculation

* feat(doc): add best-effort dimension computation to DryRun

* docs: add --width/--height to docs +media-insert SKILL.md

* fix: add SafeInputPath validation to detectImageDimensionsFromPath

* fix: guard computeMissingDimension against division by zero and add rounding

* fix: add dimension upper bound, fix err variable reuse in Execute

* refactor: use early-return guard for zero native dimensions per review

* fix: add pixels unit to dimension validation error messages

* fix: surface dimension detection failures in dry-run to match Execute behavior

* fix: move dimension detection before upload to fail fast

* fix: restore withRollbackWarning on dimension detection errors in Execute

Dimension detection runs after the placeholder block is created (Step 2),
so failures must clean up the block to avoid leaving an empty placeholder
in the document.
2026-05-15 18:28:56 +08:00
SunPeiYang996
4a45e00139 docs: add svg whiteboard support to doc v2 skill (#901)
Change-Id: Icada6fb894aaf9a00187fa68c132d3ade8223b99
2026-05-15 16:18:49 +08:00
河伯
f03138b9f0 feat(wiki): add +space-list / +node-list / +node-copy shortcuts (#392)
Introduce three new wiki shortcuts that wrap the corresponding raw APIs
with structured flags, formatted output, my_library alias handling, and
unified envelope shape, replacing the bare `lark-cli wiki spaces list`
/ `wiki nodes list` / `wiki nodes copy` flows for the common cases.

Shortcuts
- wiki +space-list (read, scopes: wiki:space:retrieve):
  lists wiki spaces. Default fetches a single page; --page-all walks
  every page capped by --page-limit (default 10, 0 = unlimited).
  Supports --page-size / --page-token / --format json|pretty|table|csv|ndjson.
  Output: {spaces, has_more, page_token} + Meta.Count. Pretty mode
  distinguishes "no spaces" from "empty page with has_more" and hints
  the caller to resume.

- wiki +node-list (read, scopes: wiki:node:retrieve):
  lists nodes in a space or under a parent. Same pagination + format
  story as +space-list. Accepts the my_library alias for --space-id
  with --as user (resolved via a shared resolveMyLibrarySpaceID helper
  extracted from +node-create); rejects my_library upfront for --as bot.

- wiki +node-copy (high-risk-write, scopes: wiki:node:copy):
  copies a node into a target space or parent. --target-space-id and
  --target-parent-node-token are mutually exclusive. Risk is marked
  high-risk-write to match the upstream API's danger: true flag, so the
  framework requires --yes. Source is preserved; subtree is copied.

Both list shortcuts pick the narrowest scope the upstream API accepts.
The framework's preflight (internal/auth/scope.go MissingScopes) does
exact-string scope matching, so declaring the broader wiki:wiki:readonly
form would wrongly reject tokens that carry only the per-API scope —
which the API itself accepts — and emit a misleading missing-scope hint.

Shared changes
- shortcuts/wiki/wiki_node_create.go: factor out resolveMyLibrarySpaceID
  so +node-list and +node-create share one my_library resolution path.
- shortcuts/wiki/shortcuts.go: register the three new shortcuts.
- skills/lark-wiki/SKILL.md and references/lark-wiki-{space,node-list,
  node-copy}.md: documentation for the new shortcuts.

Tooling
- scripts/check-doc-tokens.sh + Makefile gitleaks target:
  pre-commit check that scans skill reference docs for realistic-looking
  Lark token values without the _EXAMPLE_TOKEN placeholder convention,
  preventing gitleaks false positives.
- .gitleaks.toml: allowlist tuning.
- .gitignore: ignore .tmp/.

Tests
- shortcuts/wiki/wiki_list_copy_test.go: unit tests covering registry
  membership, declared-narrow-scope pinning, flag validation (page-size
  range, page-limit >= 0, target flag exclusivity, my_library + bot
  rejection), auto-pagination merging, --page-limit truncation
  surfacing next cursor, --page-token single-page mode, empty-slice
  serialisation, has_more hint pretty rendering, my_library user-path
  resolution, +node-copy copy-to-space / copy-to-parent + body shape,
  pretty rendering, and the high-risk-write --yes gate.
- tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go: live end-to-end
  workflow exercising the shortcut layer against a real tenant.
  Reuses an existing my_library node as a host so the test never adds
  to the top-layer quota; the copy is placed under the same host node.
- tests/cli_e2e/wiki/coverage.md: shortcut coverage entries added.

Minor cleanups
- skills/lark-doc/references/lark-doc-search.md and
  skills/lark-minutes/references/lark-minutes-search.md: replace
  realistic-looking example ou_ tokens with _EXAMPLE_ placeholders so
  scripts/check-doc-tokens.sh passes.

Change-Id: I9efb0557f477d369d7f26a09c1e154d4ab15b253

Co-authored-by: liujinkun <liujinkun@bytedance.com>
2026-05-15 14:38:18 +08:00
Cato
ed9eecf94f fix(selfupdate): use LookPath instead of Executable for binary verification (fixes #836) (#886)
* fix(selfupdate): use LookPath instead of Executable for binary verification (fixes #836)

VerifyBinary was using vfs.Executable() to find the binary to run --version against.
On Linux with global npm install, this returns the inode of the running binary (old version),
not the newly installed one that sits behind npm's bin symlink.

Switch to exec.LookPath("lark-cli") which resolves the PATH entry and follows npm's
bin symlink to the correct newly installed version, matching what the user actually runs.

* test(selfupdate): add LookPath-based tests for VerifyBinary

Add TestVerifyBinaryLookPath, TestVerifyBinaryLookPathNotFound, and
TestVerifyBinaryEmptyOutput. Expose execLookPath variable so tests can
inject a mock LookPath and cover the full VerifyBinary execution path
including version parsing and error branches.

* test(selfupdate): add os/exec import and isolate config dir in VerifyBinary tests

CodeRabbit feedback:
- Add missing os/exec import for execLookPath variable
- Add t.Setenv(LARKSUITE_CLI_CONFIG_DIR, ...) to each new test for config isolation

* test(selfupdate): extract execLookPath to separate lookpath.go

Move the execLookPath variable declaration to its own file so it is
accessible to updater.go without the test-only import cycle.

* fix(selfupdate): remove unused os/exec import from test file

* fix(selfupdate): gofmt + fold lookpath hook and restore version fences

- Move execLookPath into updater.go (drops redundant lookpath.go)
- Document package-level mock: no t.Parallel()
- Extend TestVerifyBinaryLookPath with exact-match regressions (0.0, 12.1.0 vs 2.1.0)

Co-authored-by: CatfishGG <catfishgg@users.noreply.github.com>
2026-05-14 23:30:30 +08:00
liangshuo-1
f49a2f7e14 fix(registry): wait for background meta refresh before test reset (#894)
* fix(registry): wait for background meta refresh before test reset

TestComputeMinimumScopeSet can start doBackgroundRefresh via Init() while
the next test's resetInit() mutates package-level globals the goroutine
still reads (e.g. remoteMetaURL / configuredBrand), causing data races under
-race in the coverage job.

Track the refresh goroutine with a WaitGroup and drain it at the start of
resetInit() in tests.
2026-05-14 22:33:21 +08:00
caojie0621
a93fb2d6b3 docs: add drive permission public patch error guidance (#863) 2026-05-14 21:57:55 +08:00
SunPeiYang996
7acf64c3ef docs: add v2 api version to docs fetch examples (#891)
Change-Id: I130e6e02c0b7594a05bdda6c9bf552fb15572791
2026-05-14 20:50:55 +08:00
fangshuyu-768
52e0129078 feat(drive): add quick mode to status diff (#870) 2026-05-14 20:37:39 +08:00
liangshuo-1
8a8dff47ce chore(release): v1.0.31 (#889)
Change-Id: I1609f900c4b5dc219e1e58aecb642928d418c5b3
2026-05-14 20:19:31 +08:00
SunPeiYang996
1c2d3d7679 docs: update lark-doc skill description (#890)
Change-Id: I77e2ae690b8976e37f69ae5d581fccc13917ec5e
2026-05-14 20:17:48 +08:00
wangweiming-01
0d20f88453 feat: support file-token overwrite and version output for drive +upload (#885)
Change-Id: I76c334578fc2fa5cfd2eedb4525b0d9d735f610e
2026-05-14 19:50:51 +08:00
MaxHuang22
b0bd9b0258 feat(install): skip interactive prompts in non-TTY environments (#888)
* feat(install): skip interactive prompts in non-TTY environments

Change-Id: Ieb6ffef54d3118088f16728933c55d1b21a8abfb

* docs: simplify install instructions to use npx install wizard

Change-Id: Ic970d2c879fd649c2dbd6ddf9a259bc64eb1a384
2026-05-14 19:40:14 +08:00
MaxHuang22
ba6edb84e4 feat: recommend lark-cli update over npm install for AI agents (#884)
* docs: rewrite lark-shared update section to recommend lark-cli update

Change-Id: Ie043b1a32675dcd041f9123503fcccb791cccd07

* feat: add command field to _notice JSON for AI agents

Change-Id: I04b069880f7dca8db384ba8a6919e5682c0382be

* feat: demote npm install to fallback with skills-not-synced warning

Change-Id: If21c3ef6cd1818b28f5578078a04c3627128c6d0

* fix: address CodeRabbit review — guard type assertions, remove npm fallback from SKILL.md

- Add t.Fatalf guards before type-asserting notice sub-maps in
  TestSetupNotices_BothUpdateAndSkills to prevent nil-panic on
  unexpected shapes.
- Remove the npm fallback section from SKILL.md entirely so AI agents
  only see `lark-cli update` as the update path.
- Strip remaining npm mentions from the "重要" note.

Change-Id: Ieb124763b918093e1dcae06f5ea7428dbc248d5f

* fix: add npx skills add hint alongside npm fallback in update paths

When npm is shown as a fallback (manual update path and rollback hint),
append the npx skills add command so users know how to sync skills
separately.

Change-Id: I454172be51073d35def635613a23ad35ba68b5fb
2026-05-14 19:09:10 +08:00
shifengjuan-dev
a54a879330 feat(im): add --exclude-muted to +chat-search and new +chat-list (#820)
Add im +chat-list shortcut wrapping GET /open-apis/im/v1/chats (previously not exposed via lark-cli).
Add --exclude-muted to both +chat-search and +chat-list: client-side filter that calls POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status after each page and drops is_muted=true chats.
Introduce shortcuts/im/mute_filter.go with pure helpers and an orchestrator (MaybeApplyMuteFilter) shared by both shortcuts.

Change-Id: I22221ac5835667f58cbd40b34de75825d2445d1c
2026-05-14 17:47:34 +08:00
Paulazaaza-dev
a27c636131 add addsign and rollback method (#867)
Change-Id: I0a50796cf33fd59e4222f26003efd43aa7c5896a
2026-05-14 15:13:30 +08:00
JackZhao10086
37459b60ec feat(auth): support --exclude flag and combine --scope with --domain/… (#844)
* fix(auth/login): 增加exclude参数使用校验逻辑

当使用--exclude参数时,必须同时指定--scope、--domain或--recommend中的至少一个,避免非法参数调用

* feat(auth/login): add --exclude flag and support combining scope options

1. 新增--exclude命令行标志用于排除指定的授权范围
2. 移除--scope与--domain/--recommend的互斥限制,改为叠加使用
3. 重构范围合并与排除逻辑,增加校验和辅助工具函数
4. 更新--scope参数的帮助文档说明叠加行为

* fix(auth/login): 修复登录命令scope参数描述重复的问题

移除了重复的参数说明文本,整理冗余的注释内容,让帮助文档更清晰易读

* fix(auth/login): 修复exclude参数校验逻辑

添加--exclude参数必须配合其他可选参数使用的校验,避免无效的exclude参数调用

---------

Co-authored-by: cqc-a11y <chengqingchun@bytedance.com>
2026-05-14 14:12:29 +08:00
fangshuyu-768
f1aa7d8f42 feat(drive): add modified-time smart sync mode (#859) 2026-05-14 14:10:35 +08:00
liangshuo-1
a18504b1f9 chore(release): v1.0.30 (#871)
Change-Id: Iaa769f2ddc98ece7bf36efe821d4eb192f7fc727
2026-05-13 20:11:06 +08:00
shifengjuan-dev
5e0ac02f08 feat(im): add --chat-mode topic to +chat-create (#790)
Adds --chat-mode group|topic to lark-cli im +chat-create so users and AI agents can create 话题群 (topic chats) directly via the CLI. Without this, requests to create a topic chat silently fall back to a normal conversation group. Default remains group; chat_mode is now always emitted in the POST /open-apis/im/v1/chats request body.

Change-Id: I79385e2e8606f84e3f27de240d1b41037bf51261
2026-05-13 18:03:58 +08:00
aj
b0c9a4d74e fix(auth): support comma-separated --scope in auth login (#764)
`lark-cli auth login --scope "a,b"` previously sent the raw comma-joined
string to the device authorization endpoint, which treats it as a single
malformed scope and fails with:

  device authorization failed: The provided scope list contains invalid
  or malformed scopes.

OAuth 2.0 (RFC 6749 §3.3) requires space-delimited scopes on the wire,
but commas are the more natural separator for users typing on a shell
(quoting whitespace is awkward, especially for AI-agent generated
commands). Accept both: split on commas/whitespace, trim, dedupe, then
re-join with single spaces.

Also adds unit tests covering single, comma, space, mixed, dedupe, and
trailing-separator inputs.

Co-authored-by: aj <2072584+meijing0114@users.noreply.github.com>
2026-05-13 14:27:55 +08:00
JackZhao10086
ddc24fec90 fix(auth): clarify URL handling in auth messages and docs (#856) 2026-05-13 14:09:53 +08:00
liangshuo-1
25454f498b test(update): isolate stamp writes from real ~/.lark-cli/skills.stamp (#858)
Five tests in cmd/update mocked SkillsUpdateOverride to return success
and let runSkillsAndStamp call WriteStamp, but did not isolate
LARKSUITE_CLI_CONFIG_DIR. Each run clobbered the real
~/.lark-cli/skills.stamp with the mock version ("2.0.0" or "1.0.0"),
causing skillscheck to fire a misleading drift notice on every
subsequent lark-cli invocation.

Add t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) at the top of:
  - TestUpdateNpm_JSON
  - TestUpdateNpm_Human
  - TestUpdateForce_JSON
  - TestUpdateDevVersion_JSON
  - TestUpdateWindows_NpmSuccess_JSON

Scope is limited to tests that mock SkillsUpdateOverride to success;
tests that invoke real npx are pre-existing and out of scope here.

Change-Id: I7a78a6c70f276b51333253acc115e0109c01a851
2026-05-13 13:52:22 +08:00
evandance
62ff3d66a6 fix(bind): accept ~/ paths in OpenClaw secret references (#839)
OpenClaw stores secret file paths in user-authored ~/-relative form so
the configuration stays portable across machines. lark-cli config bind
previously rejected these as non-absolute, blocking users until they
rewrote the OpenClaw config with literal absolute paths.

bind now resolves ~ to the OpenClaw home directory (OPENCLAW_HOME if
set, otherwise the OS home) before the path audit runs, mirroring how
OpenClaw itself reads the same field. Cwd-relative paths and other
unsafe locations are still rejected as before.
2026-05-13 12:34:43 +08:00
111 changed files with 8104 additions and 375 deletions

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo
.tmp/

View File

@@ -14,3 +14,4 @@ id = "lark-session-token"
description = "Detect Lark session tokens"
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
keywords = ["XN0YXJ0-", "-WVuZA"]

View File

@@ -2,6 +2,51 @@
All notable changes to this project will be documented in this file.
## [v1.0.32] - 2026-05-15
### Features
- **doc**: Add `--width`/`--height` flags to `docs +media-insert` (#832)
- **wiki**: Add `+space-list` / `+node-list` / `+node-copy` shortcuts (#392)
### Bug Fixes
- **drive**: Preserve parent token on nested overwrite (#908)
- **selfupdate**: Use `LookPath` instead of `Executable` for binary verification (#886)
- **registry**: Wait for background meta refresh before test reset (#894)
### Documentation
- **doc**: Add SVG whiteboard support to `lark-doc` v2 skill (#901)
- **drive**: Add permission public patch error guidance (#863)
## [v1.0.31] - 2026-05-14
### Features
- **install**: Skip interactive prompts in non-TTY environments (#888)
- **update**: Recommend `lark-cli update` over `npm install` for AI agents (#884)
- **im**: Add `--exclude-muted` to `+chat-search` and new `+chat-list` shortcut (#820)
- **auth**: Add `--exclude` flag and allow combining `--scope` with `--domain`/`--recommend` (#844)
- **drive**: Add modified-time smart sync mode (#859)
- **approval**: Add `tasks.add_sign` and `tasks.rollback` methods (#867)
## [v1.0.30] - 2026-05-13
### Features
- **im**: Add `--chat-mode topic` to `+chat-create` (#790)
### Bug Fixes
- **auth**: Support comma-separated `--scope` in `auth login` (#764)
- **auth**: Clarify URL handling in auth messages and docs (#856)
- **bind**: Accept `~/` paths in OpenClaw secret references (#839)
### Tests
- **update**: Isolate stamp writes from real `~/.lark-cli/skills.stamp` (#858)
## [v1.0.29] - 2026-05-12
### Features
@@ -676,6 +721,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27

View File

@@ -8,7 +8,9 @@ DATE := $(shell date +%Y-%m-%d)
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta
.PHONY: all build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks
all: test
fetch_meta:
python3 scripts/fetch_meta.py
@@ -37,3 +39,13 @@ uninstall:
clean:
rm -f $(BINARY)
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
# Step 2: gitleaks scans the full repo for real leaked secrets.
# Install gitleaks: https://github.com/gitleaks/gitleaks#installing
gitleaks:
@bash scripts/check-doc-tokens.sh
@command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks not found. Install: brew install gitleaks"; exit 1; }
gitleaks detect --redact -v --exit-code=2

View File

@@ -62,11 +62,7 @@ Choose **one** of the following methods:
**Option 1 — From npm (recommended):**
```bash
# Install CLI
npm install -g @larksuite/cli
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
npx @larksuite/cli@latest install
```
**Option 2 — From source:**
@@ -102,11 +98,7 @@ lark-cli calendar +agenda
**Step 1 — Install**
```bash
# Install CLI
npm install -g @larksuite/cli
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
npx @larksuite/cli@latest install
```
**Step 2 — Configure app credentials**

View File

@@ -62,11 +62,7 @@
**方式一 — 从 npm 安装(推荐):**
```bash
# 安装 CLI
npm install -g @larksuite/cli
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
npx @larksuite/cli@latest install
```
**方式二 — 从源码安装:**
@@ -102,11 +98,7 @@ lark-cli calendar +agenda
**第 1 步 — 安装**
```bash
# 安装 CLI
npm install -g @larksuite/cli
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
npx @larksuite/cli@latest install
```
**第 2 步 — 配置应用凭证**

View File

@@ -30,6 +30,7 @@ type LoginOptions struct {
Scope string
Recommend bool
Domains []string
Exclude []string
NoWait bool
DeviceCode string
}
@@ -62,11 +63,13 @@ browser. Run it in the background and retrieve the verification URL from its out
}
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
available := sortedKnownDomains()
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
"scopes to exclude from the request (repeatable or comma-separated, e.g. --exclude drive:file:download)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
@@ -158,6 +161,10 @@ func authLoginRun(opts *LoginOptions) error {
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
if len(opts.Exclude) > 0 && !hasAnyOption {
return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
}
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
@@ -185,14 +192,17 @@ func authLoginRun(opts *LoginOptions) error {
}
}
finalScope := opts.Scope
// Normalize --scope so users can pass either OAuth-standard space-separated
// values or the more natural comma-separated list. RFC 6749 §3.3 mandates
// space-delimited scopes in the wire request, so the device authorization
// endpoint rejects raw "a,b" strings as a single malformed scope.
finalScope := normalizeScopeInput(opts.Scope)
// Resolve scopes from domain/permission filters
// Resolve scopes from domain/permission filters and merge with --scope.
// --scope, --domain, and --recommend combine additively so callers can,
// for example, request all `docs` scopes plus a few specific `drive`
// scopes in a single command.
if len(selectedDomains) > 0 || opts.Recommend {
if opts.Scope != "" {
return output.ErrValidation("cannot use --scope together with --domain/--recommend")
}
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user")
@@ -206,11 +216,35 @@ func authLoginRun(opts *LoginOptions) error {
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
}
if len(candidateScopes) == 0 {
if len(candidateScopes) == 0 && opts.Scope == "" {
return output.ErrValidation("no matching scopes found, check domain/scope options")
}
finalScope = strings.Join(candidateScopes, " ")
// Merge --scope additively with the resolved domain scopes.
merged := make(map[string]bool, len(candidateScopes)+len(strings.Fields(finalScope)))
for _, s := range candidateScopes {
merged[s] = true
}
for _, s := range strings.Fields(finalScope) {
merged[s] = true
}
finalScope = joinSortedScopeSet(merged)
}
// Apply --exclude on top of the resolved scope set. We honour exclude
// regardless of whether scopes came from --scope, --domain, --recommend,
// or any combination thereof.
if len(opts.Exclude) > 0 {
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
if len(unknown) > 0 {
return output.ErrValidation(
"these --exclude scopes are not present in the requested set: %s",
strings.Join(unknown, ", "))
}
finalScope = excluded
if strings.TrimSpace(finalScope) == "" {
return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
}
}
// Step 1: Request device authorization
@@ -232,7 +266,7 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. Then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
@@ -473,7 +507,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.ScopesForIdentity(identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) {
scopeSet[s] = true
}
}
@@ -532,6 +566,40 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
return false
}
// normalizeScopeInput accepts a user-supplied --scope value that may use
// commas, spaces, tabs, or newlines (or any mix) as separators and returns the
// canonical OAuth 2.0 wire form: a single space-joined string with empties
// trimmed and duplicates removed (first occurrence wins; order preserved).
//
// Examples:
//
// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read"
// "a, b , c" -> "a b c"
// "a b a" -> "a b"
// "" -> ""
func normalizeScopeInput(raw string) string {
if raw == "" {
return ""
}
// Treat both commas and any whitespace as separators.
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
if len(fields) == 0 {
return ""
}
seen := make(map[string]struct{}, len(fields))
out := make([]string, 0, len(fields))
for _, f := range fields {
if _, ok := seen[f]; ok {
continue
}
seen[f] = struct{}{}
out = append(out, f)
}
return strings.Join(out, " ")
}
// suggestDomain finds the best "did you mean" match for an unknown domain.
func suggestDomain(input string, known map[string]bool) string {
// Check common cases: prefix match or input is a substring
@@ -542,3 +610,58 @@ func suggestDomain(input string, known map[string]bool) string {
}
return ""
}
// joinSortedScopeSet returns a deterministic, space-separated scope string
// from a set, sorted alphabetically. Empty/blank scopes are dropped.
func joinSortedScopeSet(set map[string]bool) string {
out := make([]string, 0, len(set))
for s := range set {
if strings.TrimSpace(s) == "" {
continue
}
out = append(out, s)
}
sort.Strings(out)
return strings.Join(out, " ")
}
// applyExcludeScopes removes the provided exclude entries from the requested
// scope string. Each --exclude flag value may itself contain comma- or
// whitespace-separated scopes. Returns the filtered scope string and any
// exclude entries that were not present in the requested set (callers can
// surface those as a validation error to catch typos like
// `--exclude drive:file:downlod`).
func applyExcludeScopes(requested string, excludes []string) (string, []string) {
requestedSet := make(map[string]bool)
for _, s := range strings.Fields(requested) {
requestedSet[s] = true
}
excludeSet := make(map[string]bool)
for _, raw := range excludes {
// --exclude already splits on commas (StringSliceVar), but also
// tolerate whitespace-separated entries inside a single value.
for _, s := range strings.Fields(strings.ReplaceAll(raw, ",", " ")) {
excludeSet[s] = true
}
}
var unknown []string
for s := range excludeSet {
if !requestedSet[s] {
unknown = append(unknown, s)
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
return requested, unknown
}
kept := make(map[string]bool, len(requestedSet))
for s := range requestedSet {
if !excludeSet[s] {
kept[s] = true
}
}
return joinSortedScopeSet(kept), nil
}

View File

@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout 600s如不支持长 timeout请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code导致用户授权链接失效。",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s如不支持长 timeout请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询**不要短 timeout 反复重试**每次重启会作废上一轮的 device code导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL把它视为不可修改的 opaque string不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout**; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",

View File

@@ -70,6 +70,32 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
}
}
func TestNormalizeScopeInput(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"single", "vc:note:read", "vc:note:read"},
{"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"mixed_separators", "a, b\tc\nd e", "a b c d e"},
{"trim_and_dedup", " a , b , a ", "a b"},
{"trailing_separators", "a,b,,", "a b"},
{"only_separators", " , , ", ""},
{"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeScopeInput(tc.in); got != tc.want {
t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
// Empty AuthTypes defaults to ["user"]
sc := common.Shortcut{AuthTypes: nil}
@@ -879,6 +905,57 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
}
}
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
NoWait: true,
})
if err != nil {
t.Fatalf("authLoginRun() error = %v", err)
}
dec := json.NewDecoder(strings.NewReader(stdout.String()))
var data map[string]interface{}
if err := dec.Decode(&data); err != nil {
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
}
hint, _ := data["hint"].(string)
for _, want := range []string{
"exactly as returned by the CLI",
"opaque string",
"Do not URL-encode or decode it",
"do not add %20, spaces, or punctuation",
"do not wrap it as Markdown link text",
"fenced code block containing only the raw URL",
} {
if !strings.Contains(hint, want) {
t.Fatalf("hint missing %q, got:\n%s", want, hint)
}
}
}
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
@@ -917,6 +994,60 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
}
}
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: ctx,
Scope: "im:message:send",
JSON: true,
})
if err == nil {
t.Fatal("expected error from cancelled context")
}
dec := json.NewDecoder(strings.NewReader(stdout.String()))
var data map[string]interface{}
if err := dec.Decode(&data); err != nil {
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
}
hint, _ := data["agent_hint"].(string)
for _, want := range []string{
"timeout >= 600s",
"逐字原样转发 CLI 返回的 URL",
"opaque string",
"不要做 URL 编码或解码",
"不要补 `%20`、空格或标点",
"不要改写成 Markdown 链接",
"只包含该 URL 的代码块单独输出",
} {
if !strings.Contains(hint, want) {
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
}
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {

View File

@@ -408,6 +408,26 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
}
}
// Env template form: secret = "${VAR}" should resolve via the SecretInput
// pipeline (same path openclaw uses), so the keychain receives the env value
// not the literal template string.
func TestConfigBindRun_LarkChannel_EnvTemplate(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clearAgentEnv(t)
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("LARK_APP_SECRET", "resolved_via_env")
writeLarkChannelFixture(t, fakeHome,
`{"accounts":{"app":{"id":"cli_lc_env","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`)
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
t.Fatalf("expected success, got error: %v", err)
}
}
// tenant: "lark" should land as Brand("lark"), not normalized to "feishu".
func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
saveWorkspace(t)

View File

@@ -312,13 +312,22 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
return nil, output.Errorf(output.ExitInternal, "lark-channel",
"internal: appID %q does not match config", appID)
}
if b.cfg.Accounts.App.Secret == "" {
if b.cfg.Accounts.App.Secret.IsZero() {
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
"run lark-channel-bridge's setup to populate the app credential")
}
stored, err := core.ForStorage(appID, core.PlainSecret(b.cfg.Accounts.App.Secret), b.opts.Factory.Keychain)
// Resolve through the same SecretInput pipeline openclaw uses, so
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
if err != nil {
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err),
fmt.Sprintf("check appSecret configuration in %s", b.path))
}
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, output.Errorf(output.ExitInternal, "lark-channel",
"keychain unavailable: %v", err)

View File

@@ -97,7 +97,7 @@ func diagBuild(domains []string) diagOutput {
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
continue
}
for _, scope := range sc.ScopesForIdentity(identity) {
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
k := methodKey{domain, "shortcut", sc.Command, scope}
if e, ok := merged[k]; ok {
e.Identity = appendUniq(e.Identity, identity)
@@ -169,6 +169,25 @@ func appendUniq(ss []string, s string) []string {
return append(ss, s)
}
func TestDiagBuild_ShortcutIncludesConditionalScopes(t *testing.T) {
out := diagBuild([]string{"drive"})
var sawMetadata, sawDownload bool
for _, method := range out.Methods {
if method.Domain != "drive" || method.Type != "shortcut" || method.Method != "+status" {
continue
}
if method.Scope == "drive:drive.metadata:readonly" {
sawMetadata = true
}
if method.Scope == "drive:file:download" {
sawDownload = true
}
}
if !sawMetadata || !sawDownload {
t.Fatalf("drive +status should advertise both metadata and conditional download scopes, saw metadata=%v download=%v", sawMetadata, sawDownload)
}
}
// ── Snapshot generation ───────────────────────────────────────────────
//
// Generates a JSON snapshot of all API methods and shortcuts with their

View File

@@ -252,7 +252,7 @@ func checkCLIUpdate() []checkResult {
if update.IsNewer(latest, current) {
return []checkResult{warn("cli_update",
fmt.Sprintf("%s → %s available", current, latest),
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
"run: lark-cli update")}
}
return []checkResult{pass("cli_update", latest+" (up to date)")}
}

View File

@@ -75,7 +75,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
continue
}
scopes := sc.ScopesForIdentity(identity)
scopes := sc.DeclaredScopesForIdentity(identity)
if len(scopes) == 0 {
return nil
}

View File

@@ -140,6 +140,7 @@ func setupNotices() {
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
}
if stale := skillscheck.GetPending(); stale != nil {
@@ -147,6 +148,7 @@ func setupNotices() {
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if len(notice) == 0 {

View File

@@ -612,6 +612,9 @@ func TestSetupNotices_Drift(t *testing.T) {
if msg, _ := skills["message"].(string); msg != want {
t.Errorf("notice.skills.message = %q, want %q", msg, want)
}
if cmd, _ := skills["command"].(string); cmd != "lark-cli update" {
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
}
}
// TestSetupNotices_BothUpdateAndSkills verifies the composed envelope
@@ -658,6 +661,20 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
if _, ok := notice["skills"].(map[string]interface{}); !ok {
t.Errorf("missing 'skills' key: %+v", notice)
}
upd, ok := notice["update"].(map[string]interface{})
if !ok {
t.Fatalf("notice.update missing or wrong type: %+v", notice)
}
if cmd, _ := upd["command"].(string); cmd != "lark-cli update" {
t.Errorf("notice.update.command = %q, want %q", cmd, "lark-cli update")
}
sk, ok := notice["skills"].(map[string]interface{})
if !ok {
t.Fatalf("notice.skills missing or wrong type: %+v", notice)
}
if cmd, _ := sk["command"].(string); cmd != "lark-cli update" {
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
}
}
// clearNoticeEnv unsets the env vars that affect either notice. We

View File

@@ -284,6 +284,32 @@ func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.
}
}
func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "drive"}
shortcutCmd := &cobra.Command{Use: "+status"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
enrichMissingScopeError(f, exitErr)
if exitErr.Detail == nil {
t.Fatal("expected error detail")
}
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
t.Fatalf("expected conditional scope hint for drive +status, got %q", exitErr.Detail.Hint)
}
}
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -227,7 +227,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
emitSkillsTextHints(io, skillsResult)
return nil
}
@@ -324,7 +324,7 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the

View File

@@ -168,6 +168,11 @@ func TestUpdateManual_Human(t *testing.T) {
}
func TestUpdateNpm_JSON(t *testing.T) {
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -195,6 +200,9 @@ func TestUpdateNpm_JSON(t *testing.T) {
}
func TestUpdateNpm_Human(t *testing.T) {
// Same isolation as TestUpdateNpm_JSON — see comment there.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -222,6 +230,9 @@ func TestUpdateNpm_Human(t *testing.T) {
}
func TestUpdateForce_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--force", "--json"})
@@ -312,6 +323,9 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
}
func TestUpdateDevVersion_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -467,6 +481,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
t.Errorf("expected manual reinstall command in hint, got: %s", out)
}
if !strings.Contains(out, "skills will not be synced") {
t.Errorf("expected skills-not-synced warning in rollback hint, got: %s", out)
}
if !strings.Contains(out, "npx skills add larksuite/cli -y -g") {
t.Errorf("expected npx skills add hint for skills sync, got: %s", out)
}
}
func TestUpdateCheck_JSON_Npm(t *testing.T) {
@@ -629,6 +649,9 @@ func TestPermissionHint(t *testing.T) {
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
// With the rename trick, Windows npm installs can now auto-update.
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})

View File

@@ -65,7 +65,11 @@ func AssertSecurePath(params AuditParams) (string, error) {
}
// requireAbsolutePath rejects relative paths; relative paths would depend on
// the process cwd and defeat the point of a static audit.
// the process cwd and defeat the point of a static audit. Shell-style
// shortcuts like `~` are home-relative, not cwd-relative — they are an
// orthogonal concern and the audit is intentionally Go-stdlib strict here.
// Callers that accept user-authored config (e.g. resolveFileRef) must
// pre-resolve any such shortcuts before passing the path in.
func requireAbsolutePath(target, label string) error {
if !filepath.IsAbs(target) {
return fmt.Errorf("%s: path must be absolute, got %q", label, target)

View File

@@ -15,6 +15,11 @@ import (
// Unknown fields are ignored — forward-compatible with future bridge versions.
type LarkChannelRoot struct {
Accounts LarkChannelAccounts `json:"accounts"`
// Secrets is an optional registry of secret providers — same shape as
// openclaw's `secrets` block. Lets bridge declare `exec` provider scripts
// (for AES-encrypted secret backends), `env` allowlists, or `file`
// indirection rules. Resolved by binding.ResolveSecretInput.
Secrets *SecretsConfig `json:"secrets,omitempty"`
}
// LarkChannelAccounts is the namespace for credential entries.
@@ -26,13 +31,17 @@ type LarkChannelAccounts struct {
}
// LarkChannelApp is the bot app credential entry.
// Bridge stores the secret as plain text — secret-resolve indirection
// (${VAR} / file: / exec:) is intentionally not supported here, matching
// the bridge's on-disk format.
//
// `Secret` accepts the full SecretInput protocol (string / "${VAR}" template /
// SecretRef object with source env|file|exec) so users can keep secrets out
// of config.json — either by referencing an env var the bridge inherits, a
// chmod-0400 file outside the bridge dir, or an exec script that decrypts a
// local AES-encrypted secret store. Aligns lark-channel with the same secret
// protocol openclaw already uses.
type LarkChannelApp struct {
ID string `json:"id"`
Secret string `json:"secret"`
Tenant string `json:"tenant"` // "feishu" | "lark"
ID string `json:"id"`
Secret SecretInput `json:"secret"`
Tenant string `json:"tenant"` // "feishu" | "lark"
}
// ReadLarkChannelConfig reads and parses ~/.lark-channel/config.json.

View File

@@ -24,8 +24,11 @@ func TestReadLarkChannelConfig_Valid(t *testing.T) {
if got := root.Accounts.App.ID; got != "cli_abc123" {
t.Errorf("ID = %q, want %q", got, "cli_abc123")
}
if got := root.Accounts.App.Secret; got != "plain_secret" {
t.Errorf("Secret = %q, want %q", got, "plain_secret")
if got := root.Accounts.App.Secret.Plain; got != "plain_secret" {
t.Errorf("Secret.Plain = %q, want %q", got, "plain_secret")
}
if root.Accounts.App.Secret.Ref != nil {
t.Errorf("expected Plain form, got SecretRef = %+v", root.Accounts.App.Secret.Ref)
}
if got := root.Accounts.App.Tenant; got != "feishu" {
t.Errorf("Tenant = %q, want %q", got, "feishu")
@@ -92,8 +95,74 @@ func TestReadLarkChannelConfig_PartialFields(t *testing.T) {
if root.Accounts.App.ID != "" {
t.Errorf("expected empty ID, got %q", root.Accounts.App.ID)
}
if root.Accounts.App.Secret != "" {
t.Errorf("expected empty Secret, got %q", root.Accounts.App.Secret)
if !root.Accounts.App.Secret.IsZero() {
t.Errorf("expected zero Secret, got %+v", root.Accounts.App.Secret)
}
}
func TestReadLarkChannelConfig_SecretEnvTemplate(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{"accounts":{"app":{"id":"cli_a","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadLarkChannelConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := root.Accounts.App.Secret.Plain; got != "${LARK_APP_SECRET}" {
t.Errorf("Secret.Plain = %q, want template string", got)
}
}
func TestReadLarkChannelConfig_SecretRefExec(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{
"accounts": {
"app": {
"id": "cli_a",
"secret": {"source": "exec", "provider": "decrypt", "id": "app-cli_a"},
"tenant": "feishu"
}
},
"secrets": {
"providers": {
"decrypt": {"source": "exec", "command": "/usr/local/bin/lark-channel-bridge", "args": ["secrets", "get"]}
}
}
}`
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadLarkChannelConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if root.Accounts.App.Secret.Ref == nil {
t.Fatal("expected SecretRef, got Plain")
}
if got := root.Accounts.App.Secret.Ref.Source; got != "exec" {
t.Errorf("Secret.Ref.Source = %q, want %q", got, "exec")
}
if got := root.Accounts.App.Secret.Ref.ID; got != "app-cli_a" {
t.Errorf("Secret.Ref.ID = %q, want %q", got, "app-cli_a")
}
if root.Secrets == nil || root.Secrets.Providers["decrypt"] == nil {
t.Errorf("expected secrets.providers[decrypt] to be parsed")
}
}
func TestReadLarkChannelConfig_SecretRefInvalidSource(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{"accounts":{"app":{"id":"cli_a","secret":{"source":"bogus","id":"x"},"tenant":"feishu"}}}`
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
if _, err := ReadLarkChannelConfig(p); err == nil {
t.Fatal("expected error for invalid secret source, got nil")
}
}

View File

@@ -23,9 +23,19 @@ func resolveFileRef(ref *SecretRef, pc *ProviderConfig) (string, error) {
return "", fmt.Errorf("file provider path is empty")
}
// OpenClaw preserves user-authored `~/...` paths verbatim on disk for
// portability and resolves them at read time. lark-cli reads the file
// raw, so we mirror that resolution here before the audit — otherwise
// an unambiguous home-relative path would be rejected by
// requireAbsolutePath, which is meant to guard against cwd-relative
// paths (a different concern). expandTildePath honours OPENCLAW_HOME so
// a tilde inside an OPENCLAW_HOME-overridden config resolves to the
// same absolute path OpenClaw itself would have used.
targetPath := expandTildePath(pc.Path)
// Security audit on file path
securePath, err := AssertSecurePath(AuditParams{
TargetPath: pc.Path,
TargetPath: targetPath,
Label: "secrets.providers file path",
TrustedDirs: pc.TrustedDirs,
AllowInsecurePath: pc.AllowInsecurePath,

View File

@@ -6,6 +6,7 @@ package binding
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -230,3 +231,88 @@ func TestResolveFileRef_ExceedsMaxBytes(t *testing.T) {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
// TestResolveFileRef_TildePath_SingleValue is the end-to-end smoke test
// for the fix: a singleValue file provider with a ~/-relative path
// resolves correctly through resolveFileRef. Before this PR the audit
// would reject the path as "must be absolute".
func TestResolveFileRef_TildePath_SingleValue(t *testing.T) {
dir := t.TempDir()
setFakeOSHome(t, dir)
t.Setenv("OPENCLAW_HOME", "")
p := filepath.Join(dir, "secret.txt")
if err := os.WriteFile(p, []byte("tilde_secret\n"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: "~/secret.txt",
Mode: "singleValue",
AllowInsecurePath: true,
}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "tilde_secret" {
t.Errorf("got %q, want %q", got, "tilde_secret")
}
}
// TestResolveFileRef_RelativePath_StillRejected guards the absolute-path
// audit: cwd-relative input must still be rejected even though tilde was
// loosened. Catches regressions if expandTildePath is ever widened to
// also expand "./..." (which would weaken the audit's invariant).
func TestResolveFileRef_RelativePath_StillRejected(t *testing.T) {
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: "relative/secret.txt",
Mode: "singleValue",
AllowInsecurePath: true,
}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for relative path, got nil")
}
wantSub := "path must be absolute"
if !strings.Contains(err.Error(), wantSub) {
t.Errorf("error = %q, want substring %q", err.Error(), wantSub)
}
}
// TestResolveFileRef_TildePath_JSONMode verifies the tilde-expansion
// path works for json mode (where ref id is a JSON pointer) as well as
// singleValue mode — the mechanism is mode-agnostic.
func TestResolveFileRef_TildePath_JSONMode(t *testing.T) {
dir := t.TempDir()
setFakeOSHome(t, dir)
t.Setenv("OPENCLAW_HOME", "")
p := filepath.Join(dir, "secrets.json")
content := `{"providers":{"feishu":{"key":"json_via_tilde"}}}`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: "/providers/feishu/key"}
pc := &ProviderConfig{
Source: "file",
Path: "~/secrets.json",
Mode: "json",
AllowInsecurePath: true,
}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "json_via_tilde" {
t.Errorf("got %q, want %q", got, "json_via_tilde")
}
}

180
internal/binding/tilde.go Normal file
View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"os/user"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
// hasTildePrefix reports whether s begins with `~` followed by end-of-string,
// `/`, or `\` — the form OpenClaw treats as home-relative.
func hasTildePrefix(s string) bool {
if s == "" || s[0] != '~' {
return false
}
if len(s) == 1 {
return true
}
return s[1] == '/' || s[1] == '\\'
}
// joinTildeSuffix expands a tilde-prefixed string against a resolved home
// directory. Replaces only the leading `~` so the original separator
// (forward or back slash) and suffix bytes are kept verbatim, matching
// OpenClaw's `input.replace(/^~(?=$|[\\/])/, home)` semantics rather than
// going through filepath.Join (which would silently drop a literal `\` on
// POSIX). filepath.Clean is applied so `..` and duplicate separators are
// collapsed in the same way Node's path.resolve does on each platform.
//
// Caller must ensure hasTildePrefix(s) is true and home is non-empty.
func joinTildeSuffix(s, home string) string {
if len(s) == 1 {
return home
}
return filepath.Clean(home + s[1:])
}
// normalizeSentinel applies OpenClaw's normalize() helper to a single
// string: trims whitespace and treats the JS-flavoured literals
// "undefined" / "null" (along with empty/whitespace-only) as unset.
func normalizeSentinel(v string) string {
v = strings.TrimSpace(v)
if v == "undefined" || v == "null" {
return ""
}
return v
}
// osHome returns the OS-level home directory by walking OpenClaw's
// resolution chain: HOME → USERPROFILE → OS user database (getpwuid on
// Unix / user32 on Windows, via os/user.Current). Each candidate is
// passed through normalizeSentinel so sentinel literals and blank
// strings fall through.
//
// Matches OpenClaw's resolveRawOsHomeDir env chain so the same tilde
// resolves against the same home under mixed shell environments and
// accidentally-stringified env values. Go's stdlib os.UserHomeDir on
// Unix only re-reads HOME and gives up; Node's os.homedir() still
// returns the account home via the user database, so the explicit
// user.Current() step is what keeps OpenClaw-authored `~/...` working
// in HOME-unset shells.
//
// Deliberate hybrid contract — neither a strict mirror of OpenClaw
// nor a strict reject-on-missing:
//
// - OpenClaw's final fallback is cwd (via resolveRequiredHomeDir →
// process.cwd()). We don't do that because requireAbsolutePath
// exists precisely to reject cwd-dependent paths; routing
// `~/secret` through cwd would defeat the audit invariant.
//
// - We still go through user.Current() before giving up, even when
// HOME is a sentinel literal ("undefined" / "null") and
// USERPROFILE is unset. At that point OpenClaw would land on cwd,
// and a strict implementation would reject; user.Current() lands
// on the account home instead — cwd-independent and user-bound,
// so it satisfies the audit's safety goal while still letting
// ~/-authored configs resolve in a malformed-env shell.
//
// - Only returns "" when the env chain AND user.Current() are all
// unresolvable, at which point the caller surfaces a clean
// "path must be absolute" error from the audit.
func osHome() string {
if v := normalizeSentinel(os.Getenv("HOME")); v != "" {
return v
}
if v := normalizeSentinel(os.Getenv("USERPROFILE")); v != "" {
return v
}
if u, err := user.Current(); err == nil {
return normalizeSentinel(u.HomeDir)
}
return ""
}
// explicitOpenClawHome reads OPENCLAW_HOME with OpenClaw's normalize()
// semantics applied.
func explicitOpenClawHome() string {
return normalizeSentinel(os.Getenv("OPENCLAW_HOME"))
}
// absolutize returns p as an absolute path, resolving against the process
// cwd when p is relative. Returns "" when the cwd cannot be resolved.
// Wraps filepath.Abs semantics via vfs.Getwd because forbidigo bans
// filepath.Abs inside internal/ packages.
func absolutize(p string) string {
if p == "" {
return ""
}
if filepath.IsAbs(p) {
return filepath.Clean(p)
}
wd, err := vfs.Getwd()
if err != nil {
return ""
}
return filepath.Join(wd, p)
}
// openClawHome returns the home directory used to resolve `~`-relative paths
// authored against OpenClaw's config. Closely mirrors OpenClaw's
// home-resolution semantics so the same tilde resolves to the same
// absolute path here as inside OpenClaw runtime under all normal
// conditions.
//
// Resolution order:
// 1. OPENCLAW_HOME env var, when set (sentinel-normalised).
// 2. If OPENCLAW_HOME itself has a tilde prefix, expand it against the OS
// home (see osHome); the result is empty when the OS home is
// unresolvable.
// 3. Otherwise fall back to the OS home.
//
// The returned path is absolute (relative OPENCLAW_HOME values are
// absolutised against the process cwd, matching Node path.resolve in
// OpenClaw's pipeline).
//
// Returns "" when no home can be resolved. This is a deliberate
// divergence from OpenClaw, whose read pipeline would fall back to
// cwd via resolveRequiredHomeDir — see osHome for the rationale.
func openClawHome() string {
raw := explicitOpenClawHome()
switch {
case raw == "":
raw = osHome()
case hasTildePrefix(raw):
h := osHome()
if h == "" {
return ""
}
raw = joinTildeSuffix(raw, h)
}
return absolutize(raw)
}
// expandTildePath resolves a leading `~` or `~/...` prefix to OpenClaw's
// effective home directory (see openClawHome).
//
// Returns the input unchanged when it lacks a tilde prefix or when
// openClawHome cannot resolve a home directory. The latter case is a
// deliberate divergence from OpenClaw, whose read pipeline falls back
// to cwd — see osHome. Surfacing a "path must be absolute" error from
// the audit is preferable to silently routing a user-authored
// `~/secret` through cwd resolution.
//
// `~user` shell-style expansion is intentionally not supported (OpenClaw
// does not support it either).
func expandTildePath(p string) string {
if !hasTildePrefix(p) {
return p
}
home := openClawHome()
if home == "" {
return p
}
return joinTildeSuffix(p, home)
}

View File

@@ -0,0 +1,293 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"os/user"
"path/filepath"
"runtime"
"strings"
"testing"
)
// setFakeOSHome controls osHome's env-chain inputs (HOME and USERPROFILE)
// in one call so tests stay deterministic across platforms. osHome reads
// HOME first, then USERPROFILE, then user.Current(); setting only one of
// the two leaves the test sensitive to whichever the runner happens to
// have populated. Passing dir == "" disables both env entries so tests
// can exercise the user.Current() fallback or no-home edge cases.
func setFakeOSHome(t *testing.T, dir string) {
t.Helper()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
}
// isolateRuntimeWrites parks the process cwd in a fresh TempDir for the
// test's duration. Tests that set HOME to a sentinel literal trigger Go
// runtime side effects — most visibly the telemetry subsystem, which
// calls os.UserConfigDir() (= "$HOME/Library/Application Support" on
// darwin) and happily writes through a relative result like
// "undefined/Library/...". Without isolation those files land in the
// package or repo dir and get accidentally staged. Chdir'ing into a
// TempDir routes the noise into a path testing.T auto-cleans.
func isolateRuntimeWrites(t *testing.T) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(orig)
})
}
// TestOpenClawHome covers the openClawHome resolution table: empty /
// sentinel OPENCLAW_HOME falls back to the OS home, explicit absolute
// values are used verbatim (with whitespace trimmed), and tilde-prefixed
// values recurse through the OS home.
func TestOpenClawHome(t *testing.T) {
homeDir := t.TempDir()
explicit := t.TempDir()
setFakeOSHome(t, homeDir)
tests := []struct {
name string
openclawEnv string
want string
}{
{"unset falls back to OS home", "", homeDir},
{"undefined literal treated as unset", "undefined", homeDir},
{"null literal treated as unset", "null", homeDir},
{"whitespace-only treated as unset", " ", homeDir},
{"explicit absolute path used verbatim", explicit, explicit},
{"explicit absolute path is trimmed", " " + explicit + " ", explicit},
{"bare tilde resolves to OS home", "~", homeDir},
{"tilde-prefixed value recurses through OS home", "~/custom", filepath.Join(homeDir, "custom")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("OPENCLAW_HOME", tc.openclawEnv)
got := openClawHome()
if got != tc.want {
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
}
})
}
}
// TestOpenClawHome_RelativeIsAbsolutized confirms a relative
// OPENCLAW_HOME is resolved against the process cwd, mirroring Node's
// path.resolve behaviour in OpenClaw.
func TestOpenClawHome_RelativeIsAbsolutized(t *testing.T) {
t.Setenv("OPENCLAW_HOME", filepath.FromSlash("relative/dir"))
got := openClawHome()
if !filepath.IsAbs(got) {
t.Fatalf("openClawHome() = %q, want absolute path", got)
}
wantSuffix := filepath.FromSlash("relative/dir")
if !strings.HasSuffix(got, wantSuffix) {
t.Errorf("openClawHome() = %q, want suffix %q", got, wantSuffix)
}
}
// TestOpenClawHome_FallsBackToUserDatabase pins osHome's final fallback
// to the OS user database when HOME and USERPROFILE are both unset,
// matching Node's os.homedir() (which uses getpwuid). Cwd-independent
// and user-bound, so it does not conflict with the "no cwd fallback"
// rule documented on osHome.
func TestOpenClawHome_FallsBackToUserDatabase(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "")
got := openClawHome()
if got != u.HomeDir {
t.Errorf("openClawHome() = %q, want %q (account home from user.Current)", got, u.HomeDir)
}
}
// TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback pins that
// a tilde-form OPENCLAW_HOME ("~/custom") expands against the
// user-database fallback when HOME and USERPROFILE are both unset.
// Without the user.Current() step in osHome this would have failed
// (returning "") and dropped the bind back to the audit's
// "path must be absolute" error.
func TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "~/custom")
got := openClawHome()
want := filepath.Join(u.HomeDir, "custom")
if got != want {
t.Errorf("openClawHome() = %q, want %q", got, want)
}
}
// TestExpandTildePath covers the full input grid for expandTildePath:
// bare tilde, tilde-slash, tilde + suffix, nested suffix, plain absolute
// and relative literals, and the intentionally-unchanged forms (~user,
// ~foo) that OpenClaw does not expand either.
func TestExpandTildePath(t *testing.T) {
fakeHome := t.TempDir()
absFixture := filepath.Join(fakeHome, "abs.json")
setFakeOSHome(t, fakeHome)
t.Setenv("OPENCLAW_HOME", "")
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"bare tilde", "~", fakeHome},
{"tilde slash", "~/", fakeHome},
{"tilde with file", "~/secret.json", filepath.Join(fakeHome, "secret.json")},
{"tilde with nested path", "~/.openclaw/secret.json", filepath.Join(fakeHome, ".openclaw/secret.json")},
{"absolute unchanged", absFixture, absFixture},
{"relative unchanged", "foo/bar", "foo/bar"},
{"dot relative unchanged", "../foo", "../foo"},
{"tilde user form unchanged", "~root/foo", "~root/foo"},
{"tilde without separator unchanged", "~foo", "~foo"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := expandTildePath(tc.in)
if got != tc.want {
t.Errorf("expandTildePath(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestExpandTildePath_RespectsOpenClawHome verifies that with
// OPENCLAW_HOME set, tilde expansion uses that custom home rather than
// the OS home — the integration-level invariant that closes the
// internal inconsistency CodeX's first review flagged.
func TestExpandTildePath_RespectsOpenClawHome(t *testing.T) {
homeDir := t.TempDir()
clawHome := t.TempDir()
setFakeOSHome(t, homeDir)
t.Setenv("OPENCLAW_HOME", clawHome)
got := expandTildePath("~/secret.json")
want := filepath.Join(clawHome, "secret.json")
if got != want {
t.Errorf("expandTildePath(%q) = %q, want %q (should use OPENCLAW_HOME)", "~/secret.json", got, want)
}
if got == filepath.Join(homeDir, "secret.json") {
t.Errorf("expandTildePath unexpectedly used OS home %q instead of OPENCLAW_HOME %q", homeDir, clawHome)
}
}
// TestExpandTildePath_FallsBackToUserDatabase is the end-to-end
// equivalent of TestOpenClawHome_FallsBackToUserDatabase: with HOME and
// USERPROFILE both unset, expandTildePath still resolves `~/foo` via
// osHome's user.Current() step. Matches Node os.homedir() and keeps
// OpenClaw-authored configs working in minimal-env shells.
func TestExpandTildePath_FallsBackToUserDatabase(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "")
got := expandTildePath("~/foo")
want := filepath.Join(u.HomeDir, "foo")
if got != want {
t.Errorf("expandTildePath(~/foo) = %q, want %q", got, want)
}
}
// TestOpenClawHome_OSHomeNormalization pins OpenClaw's sentinel
// normalisation on the env chain: the literals "undefined" / "null" /
// blank-or-whitespace are all treated as unset, so a JS-flavoured
// accidentally-stringified env value (e.g. `HOME=undefined` from a
// shell wrapper) doesn't end up as a literal directory component when
// the user authored `~/secret`. Combined with the user.Current()
// fallback further down (see TestOpenClawHome_FallsBackToUserDatabase),
// the contract is: a malformed HOME falls through to USERPROFILE first,
// and only if that's also unset/sentinel do we go to the user database.
func TestOpenClawHome_OSHomeNormalization(t *testing.T) {
isolateRuntimeWrites(t)
userProfileDir := t.TempDir()
homeWinsDir := t.TempDir()
tests := []struct {
name string
home string
userProfile string
want string
}{
{"HOME=undefined falls through to USERPROFILE", "undefined", userProfileDir, userProfileDir},
{"HOME=null falls through to USERPROFILE", "null", userProfileDir, userProfileDir},
{"HOME=whitespace falls through to USERPROFILE", " ", userProfileDir, userProfileDir},
{"HOME wins over USERPROFILE when both are valid", homeWinsDir, userProfileDir, homeWinsDir},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HOME", tc.home)
t.Setenv("USERPROFILE", tc.userProfile)
t.Setenv("OPENCLAW_HOME", "")
if got := openClawHome(); got != tc.want {
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
}
})
}
}
// TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd pins the
// deliberate hybrid documented on osHome: with HOME a sentinel literal
// and USERPROFILE unset, OpenClaw would fall back to process.cwd();
// this implementation falls to the OS user database instead. The
// account home is both safer (cwd-independent) and more useful (it is
// where the user originally authored `~/...` against), so we prefer it
// over either OpenClaw's cwd fallback or a strict reject.
func TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd(t *testing.T) {
isolateRuntimeWrites(t)
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
t.Setenv("HOME", "undefined")
t.Setenv("USERPROFILE", "")
t.Setenv("OPENCLAW_HOME", "")
got := openClawHome()
if got != u.HomeDir {
t.Errorf("openClawHome() = %q, want %q (account home, not cwd)", got, u.HomeDir)
}
}
// TestExpandTildePath_BackslashPreservedOnPOSIX pins that `~\secret.json`
// expands by replacing only the `~` byte, leaving the backslash literally
// as part of the filename — matching OpenClaw's regex-replace semantics
// (`/^~(?=$|[\\/])/`) rather than going through filepath.Join (which would
// drop the backslash on POSIX). On Windows backslash is a real separator,
// so the literal-byte invariant doesn't apply.
func TestExpandTildePath_BackslashPreservedOnPOSIX(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("backslash is a path separator on Windows; invariant only applies on POSIX")
}
fakeHome := t.TempDir()
setFakeOSHome(t, fakeHome)
t.Setenv("OPENCLAW_HOME", "")
got := expandTildePath(`~\secret.json`)
want := fakeHome + `\secret.json`
if got != want {
t.Errorf("expandTildePath(%q) = %q, want %q (backslash should be preserved as filename byte)", `~\secret.json`, got, want)
}
}

View File

@@ -255,11 +255,18 @@ func doSyncFetch() {
// --- background refresh ---
var refreshOnce sync.Once
var (
refreshOnce sync.Once
bgRefreshInFlight sync.WaitGroup // tracks doBackgroundRefresh goroutines for test teardown (resetInit)
)
func triggerBackgroundRefresh() {
refreshOnce.Do(func() {
go doBackgroundRefresh()
bgRefreshInFlight.Add(1)
go func() {
defer bgRefreshInFlight.Done()
doBackgroundRefresh()
}()
})
}

View File

@@ -17,8 +17,18 @@ import (
"github.com/larksuite/cli/internal/core"
)
// waitBackgroundRefresh blocks until any in-flight background refresh started by
// triggerBackgroundRefresh has finished. Lives in this _test file so production
// binaries cannot call it and accidentally block on test teardown state.
func waitBackgroundRefresh() {
bgRefreshInFlight.Wait()
}
// resetInit resets the package-level state so each test starts fresh.
func resetInit() {
// Must wait: a prior test's Init() may have started doBackgroundRefresh which
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
waitBackgroundRefresh()
initOnce = sync.Once{}
mergedServices = make(map[string]map[string]interface{})
mergedProjectList = nil

View File

@@ -17,6 +17,13 @@ import (
"github.com/larksuite/cli/internal/vfs"
)
// execLookPath is the LookPath implementation used by VerifyBinary.
// It defaults to the standard library exec.LookPath but is swapped in tests
// via lookPathMock to provide controlled binary resolution.
//
// Tests that mutate execLookPath must not call t.Parallel().
var execLookPath = exec.LookPath
// InstallMethod describes how the CLI was installed.
type InstallMethod int
@@ -186,13 +193,13 @@ func (u *Updater) VerifyBinary(expectedVersion string) error {
if u.VerifyOverride != nil {
return u.VerifyOverride(expectedVersion)
}
// Prefer the current executable path (what the user actually launched).
// Use Executable() directly without EvalSymlinks — after npm install the
// symlink target may have changed, but the path itself is still valid for
// execution. Fall back to LookPath only if Executable() fails entirely.
exe, err := vfs.Executable()
// Prefer PATH resolution so npm global bin symlinks pick up the newly
// installed binary (#836). If `lark-cli` is not on PATH (e.g. the user
// invoked this process by absolute path), fall back to the running
// executable — same as the pre-#836 secondary resolution path.
exe, err := execLookPath("lark-cli")
if err != nil {
exe, err = exec.LookPath("lark-cli")
exe, err = vfs.Executable()
if err != nil {
return fmt.Errorf("cannot locate binary: %w", err)
}

View File

@@ -4,6 +4,7 @@
package selfupdate
import (
"fmt"
"os"
"path/filepath"
"runtime"
@@ -12,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/vfs"
)
// executableTestFS mocks vfs for tests that still need vfs.Executable.
type executableTestFS struct {
vfs.OsFs
exe string
@@ -19,6 +21,28 @@ type executableTestFS struct {
func (f executableTestFS) Executable() (string, error) { return f.exe, nil }
// lookPathMock patches execLookPath within VerifyBinary for controlled testing.
// Do not use t.Parallel() in tests that install this mock — it mutates a package-level var.
type lookPathMock struct {
oldLookPath func(string) (string, error)
result string
resultErr error
}
func (m *lookPathMock) install(bin string) {
m.oldLookPath = execLookPath
execLookPath = func(name string) (string, error) {
if name == bin {
return m.result, m.resultErr
}
return m.oldLookPath(name)
}
}
func (m *lookPathMock) restore() {
execLookPath = m.oldLookPath
}
func TestResolveExe(t *testing.T) {
u := New()
p, err := u.resolveExe()
@@ -44,46 +68,101 @@ func TestCleanupStaleFiles_NoPanic(t *testing.T) {
u.CleanupStaleFiles()
}
func TestVerifyBinaryChecksVersion(t *testing.T) {
func TestVerifyBinaryLookPath(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
exe := filepath.Join(dir, "lark-cli")
// Script prints version string matching real CLI format when --version is passed.
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.0.0\"; exit 0; fi\nexit 12\n"
if err := os.WriteFile(exe, []byte(script), 0755); err != nil {
bin := filepath.Join(dir, "lark-cli")
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.1.0\"; exit 0; fi\nexit 12\n"
if err := os.WriteFile(bin, []byte(script), 0755); err != nil {
t.Fatalf("write test binary: %v", err)
}
// Mock vfs.Executable to return our test script, matching VerifyBinary's
// primary lookup path. Also prepend to PATH for the LookPath fallback.
origFS := vfs.DefaultFS
vfs.DefaultFS = executableTestFS{OsFs: vfs.OsFs{}, exe: exe}
t.Cleanup(func() { vfs.DefaultFS = origFS })
mock := &lookPathMock{result: bin}
mock.install("lark-cli")
t.Cleanup(mock.restore)
origPath := os.Getenv("PATH")
t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath)
// Matching version → success.
if err := New().VerifyBinary("2.0.0"); err != nil {
t.Fatalf("VerifyBinary(matching) error = %v, want nil", err)
if err := New().VerifyBinary("2.1.0"); err != nil {
t.Fatalf("VerifyBinary(2.1.0) error = %v, want nil", err)
}
// Mismatched version → error.
if err := New().VerifyBinary("3.0.0"); err == nil {
t.Fatal("VerifyBinary(mismatched) expected error, got nil")
}
// Substring of actual version must not match (e.g. "0.0" is in "2.0.0").
// Regression: version must match exactly (not substring / prefix).
if err := New().VerifyBinary("0.0"); err == nil {
t.Fatal("VerifyBinary(substring) expected error, got nil")
t.Fatal("VerifyBinary(substring-style mismatch) expected error, got nil")
}
// Version that is a prefix of actual must not match (e.g. "2.0.0" in "12.0.0").
// Binary reports "2.0.0", asking for "12.0.0" must fail.
if err := New().VerifyBinary("12.0.0"); err == nil {
t.Fatal("VerifyBinary(prefix-mismatch) expected error, got nil")
if err := New().VerifyBinary("12.1.0"); err == nil {
t.Fatal("VerifyBinary(prefix-style mismatch) expected error, got nil")
}
}
func TestVerifyBinaryLookPathNotFound(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mock := &lookPathMock{result: "", resultErr: fmt.Errorf("not found")}
mock.install("lark-cli")
t.Cleanup(mock.restore)
oldFS := vfs.DefaultFS
t.Cleanup(func() { vfs.DefaultFS = oldFS })
// Without this, VerifyBinary would fall back to the real test binary, which
// is not a lark-cli --version implementation.
vfs.DefaultFS = executableTestFS{exe: filepath.Join(t.TempDir(), "missing-lark-cli")}
if err := New().VerifyBinary("2.0.0"); err == nil {
t.Fatal("VerifyBinary(not-found) expected error, got nil")
}
}
func TestVerifyBinaryFallbackExecutableWhenNotOnPath(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
bin := filepath.Join(dir, "lark-cli-abs")
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.1.0\"; exit 0; fi\nexit 12\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write test binary: %v", err)
}
mock := &lookPathMock{result: "", resultErr: fmt.Errorf("not on PATH")}
mock.install("lark-cli")
t.Cleanup(mock.restore)
oldFS := vfs.DefaultFS
t.Cleanup(func() { vfs.DefaultFS = oldFS })
vfs.DefaultFS = executableTestFS{exe: bin}
if err := New().VerifyBinary("2.1.0"); err != nil {
t.Fatalf("VerifyBinary(fallback executable) error = %v, want nil", err)
}
}
func TestVerifyBinaryEmptyOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
bin := filepath.Join(dir, "lark-cli")
script := "#!/bin/sh\necho\nexit 0\n"
if err := os.WriteFile(bin, []byte(script), 0755); err != nil {
t.Fatalf("write test binary: %v", err)
}
mock := &lookPathMock{result: bin}
mock.install("lark-cli")
t.Cleanup(mock.restore)
if err := New().VerifyBinary("2.0.0"); err == nil {
t.Fatal("VerifyBinary(empty output) expected error, got nil")
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.29",
"version": "1.0.32",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

66
scripts/check-doc-tokens.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
#
# check-doc-tokens.sh
#
# Scans skill reference docs for token-like values that look realistic but
# are not using the required placeholder format (*_EXAMPLE_TOKEN or similar).
#
# Real token patterns (Lark API) often look like:
# wikcnXXXXXXXXX doccnXXXXXXX shtcnXXX fldcnXXX ou_XXXX cli_XXXX
#
# Docs MUST use clearly fake placeholders, e.g.:
# wikcn_EXAMPLE_TOKEN doccn_EXAMPLE_TOKEN <space_id> your_token_here
#
# If this check fails, replace the realistic-looking value with a placeholder
# like `wikcn_EXAMPLE_TOKEN` so gitleaks CI won't flag it as a real secret.
set -euo pipefail
SKILLS_DIR="${1:-skills}"
ERRORS=0
# Patterns that indicate a realistic-looking Lark token value.
# Three forms are detected:
# 1. JSON-style quoted strings: "field": "token_value"
# 2. Markdown backtick spans: `token_value`
# 3. Bare tokens: --flag wikcnABC123 (e.g. inside fenced code blocks)
#
# Token prefixes used by Lark Open Platform:
# wikcn doccn docx shtcn bascn fldcn vewcn tbln ou_ cli_ obcn flec
#
# Excluded (clearly fake, matched by PLACEHOLDER_RE below):
# - Values containing EXAMPLE / _TOKEN / XXXX / your_ / _here
# - Angle-bracket placeholders <your_token>
# Require at least one digit in the suffix — real API tokens are always alphanumeric
# with digits. Pure-letter suffixes (e.g. ou_manager, ou_director) are clearly fake names.
PREFIXES='(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)'
TOKEN_BODY="${PREFIXES}"'[A-Za-z0-9]*[0-9][A-Za-z0-9]{3,}'
REALISTIC_TOKEN_RE="\"${TOKEN_BODY}\"|\`${TOKEN_BODY}\`|\\b${TOKEN_BODY}\\b"
PLACEHOLDER_RE='(EXAMPLE|_TOKEN|XXXX|xxxx|<|>|your_|_here)'
while IFS= read -r -d '' file; do
# grep returns exit 1 when no match — use || true to avoid set -e killing us
# Then filter out values that are clearly placeholders (EXAMPLE, XXXX, etc.)
matches=$(grep -nEo "$REALISTIC_TOKEN_RE" "$file" 2>/dev/null | grep -vE "$PLACEHOLDER_RE" || true)
if [[ -n "$matches" ]]; then
echo ""
echo "$file"
echo " Contains realistic-looking token values that may trigger gitleaks:"
while IFS= read -r line; do
echo " $line"
done <<< "$matches"
echo " → Replace with a placeholder, e.g.: wikcn_EXAMPLE_TOKEN, doccn_EXAMPLE_TOKEN"
ERRORS=$((ERRORS + 1))
fi
done < <(find "$SKILLS_DIR" -path "*/references/*.md" -print0)
if [[ $ERRORS -gt 0 ]]; then
echo ""
echo "❌ check-doc-tokens: $ERRORS file(s) contain realistic token values in reference docs."
echo " Use _EXAMPLE_TOKEN placeholders to avoid false positives in gitleaks CI."
exit 1
else
echo "✅ check-doc-tokens: all reference docs use safe placeholder tokens."
fi

View File

@@ -44,6 +44,7 @@ const messages = {
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
done: "安装完成!\n可以和你的 AI 工具(如 Claude Code、Trae等\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
cancelled: "安装已取消",
nonTtyHint: "要完成配置,请在终端中运行:\n lark-cli config init --new\n lark-cli auth login",
},
en: {
setup: "Setting up Feishu/Lark CLI...",
@@ -72,6 +73,7 @@ const messages = {
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
cancelled: "Installation cancelled",
nonTtyHint: "To complete setup, run interactively:\n lark-cli config init --new\n lark-cli auth login",
},
};
@@ -353,17 +355,23 @@ async function stepAuthLogin(msg) {
// ---------------------------------------------------------------------------
async function main() {
const lang = await stepSelectLang();
const isInteractive = !!process.stdin.isTTY;
const lang = isInteractive ? await stepSelectLang() : (parseLangArg() || "en");
const msg = messages[lang];
p.intro(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
await stepConfigInit(msg, lang);
await stepAuthLogin(msg);
p.outro(msg.done);
if (isInteractive) {
p.intro(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
await stepConfigInit(msg, lang);
await stepAuthLogin(msg);
p.outro(msg.done);
} else {
console.log(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
console.log(msg.nonTtyHint);
}
}
main().catch((err) => {

View File

@@ -33,9 +33,18 @@ type Shortcut struct {
Command string
Description string
Risk string // "read" | "write" | "high-risk-write" (empty defaults to "read")
Scopes []string // default scopes (fallback when UserScopes/BotScopes are empty)
UserScopes []string // optional: user-identity scopes (overrides Scopes when non-empty)
BotScopes []string // optional: bot-identity scopes (overrides Scopes when non-empty)
Scopes []string // unconditional pre-flight scopes (fallback when UserScopes/BotScopes are empty)
UserScopes []string // optional: user-identity unconditional scopes (overrides Scopes when non-empty)
BotScopes []string // optional: bot-identity unconditional scopes (overrides Scopes when non-empty)
// ConditionalScopes are additional scopes that only some execution paths
// need (for example a default mode vs. a lighter --quick mode, or a
// destructive flag like --delete-remote). They are surfaced in metadata,
// auth hints, and scope-diagnosis output via DeclaredScopesForIdentity, but
// they are NOT enforced by the framework's unconditional pre-flight check.
ConditionalScopes []string // fallback when ConditionalUserScopes/BotScopes are empty
ConditionalUserScopes []string // optional: user-identity conditional scopes
ConditionalBotScopes []string // optional: bot-identity conditional scopes
// Declarative fields (new framework).
AuthTypes []string // supported identities: "user", "bot" (default: ["user"])
@@ -72,3 +81,47 @@ func (s *Shortcut) ScopesForIdentity(identity string) []string {
}
return s.Scopes
}
// ConditionalScopesForIdentity returns additional flag/path-dependent scopes
// for the given identity. Identity-specific conditional scopes override the
// default ConditionalScopes when present.
func (s *Shortcut) ConditionalScopesForIdentity(identity string) []string {
switch identity {
case "user":
if len(s.ConditionalUserScopes) > 0 {
return s.ConditionalUserScopes
}
case "bot":
if len(s.ConditionalBotScopes) > 0 {
return s.ConditionalBotScopes
}
}
return s.ConditionalScopes
}
// DeclaredScopesForIdentity returns the full scope set agents/help/diagnostics
// should know about for this shortcut: unconditional pre-flight scopes plus
// any conditional scopes that some execution paths may require.
func (s *Shortcut) DeclaredScopesForIdentity(identity string) []string {
base := s.ScopesForIdentity(identity)
extra := s.ConditionalScopesForIdentity(identity)
if len(base) == 0 && len(extra) == 0 {
return nil
}
out := make([]string, 0, len(base)+len(extra))
seen := make(map[string]struct{}, len(base)+len(extra))
for _, scope := range append(base, extra...) {
if scope == "" {
continue
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
out = append(out, scope)
}
if len(out) == 0 {
return nil
}
return out
}

View File

@@ -71,3 +71,37 @@ func TestScopesForIdentity_NilScopes(t *testing.T) {
t.Errorf("expected nil, got %v", got)
}
}
func TestConditionalScopesForIdentity_FallbackAndOverrides(t *testing.T) {
s := Shortcut{
ConditionalScopes: []string{"c-default"},
ConditionalUserScopes: []string{"c-user"},
ConditionalBotScopes: []string{"c-bot"},
}
if got := s.ConditionalScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"c-user"}) {
t.Errorf("expected user conditional scopes, got %v", got)
}
if got := s.ConditionalScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"c-bot"}) {
t.Errorf("expected bot conditional scopes, got %v", got)
}
if got := s.ConditionalScopesForIdentity("tenant"); !reflect.DeepEqual(got, []string{"c-default"}) {
t.Errorf("expected default conditional scopes for unknown identity, got %v", got)
}
}
func TestDeclaredScopesForIdentity_MergesAndDeduplicates(t *testing.T) {
s := Shortcut{
Scopes: []string{"base-a", "shared"},
ConditionalScopes: []string{"shared", "cond-b"},
}
if got := s.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"base-a", "shared", "cond-b"}) {
t.Errorf("expected merged declared scopes, got %v", got)
}
}
func TestDeclaredScopesForIdentity_ConditionalOnly(t *testing.T) {
s := Shortcut{ConditionalScopes: []string{"cond-only"}}
if got := s.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"cond-only"}) {
t.Errorf("expected conditional-only declared scopes, got %v", got)
}
}

View File

@@ -7,6 +7,11 @@ import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"path/filepath"
"strings"
@@ -55,6 +60,8 @@ var DocMediaInsert = common.Shortcut{
{Name: "selection-with-ellipsis", Desc: "plain text (or 'start...end' to disambiguate) matching the target block's content. Media is inserted at the top-level ancestor of the matched block — i.e., when the selection is inside a callout, table cell, or nested list, media lands outside that container, not inside it. Pass 'start...end' (a unique prefix and suffix separated by '...') when the plain text appears in more than one block"},
{Name: "before", Type: "bool", Desc: "insert before the matched block instead of after (requires --selection-with-ellipsis)"},
{Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"},
{Name: "width", Type: "int", Desc: "image display width in pixels (only for --type=image); if --height is omitted it is auto-computed from the source image aspect ratio"},
{Name: "height", Type: "int", Desc: "image display height in pixels (only for --type=image); if --width is omitted it is auto-computed from the source image aspect ratio"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
@@ -93,6 +100,24 @@ var DocMediaInsert = common.Shortcut{
return output.ErrValidation("--file-view only applies when --type=file")
}
}
widthChanged := runtime.Changed("width")
heightChanged := runtime.Changed("height")
if (widthChanged || heightChanged) && runtime.Str("type") != "image" {
return output.ErrValidation("--width/--height only apply when --type=image")
}
if widthChanged && runtime.Int("width") <= 0 {
return output.ErrValidation("--width must be a positive integer")
}
if heightChanged && runtime.Int("height") <= 0 {
return output.ErrValidation("--height must be a positive integer")
}
const maxDimension = 10000
if widthChanged && runtime.Int("width") > maxDimension {
return output.ErrValidation("--width must not exceed %d pixels", maxDimension)
}
if heightChanged && runtime.Int("height") > maxDimension {
return output.ErrValidation("--height must not exceed %d pixels", maxDimension)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -120,7 +145,25 @@ var DocMediaInsert = common.Shortcut{
} else {
createBlockData["index"] = "<children_len>"
}
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption)
// Best-effort dimension computation for dry-run.
dryWidth := runtime.Int("width")
dryHeight := runtime.Int("height")
widthChanged := runtime.Changed("width")
heightChanged := runtime.Changed("height")
if (widthChanged || heightChanged) && !(widthChanged && heightChanged) {
if filePath == "<clipboard image>" {
fmt.Fprintf(runtime.IO().ErrOut, "Note: cannot detect clipboard image dimensions in dry-run; provide both --width and --height for accurate preview\n")
} else if nativeW, nativeH, err := detectImageDimensionsFromPath(runtime.FileIO(), filePath); err == nil {
dims := computeMissingDimension(dryWidth, dryHeight, nativeW, nativeH)
dryWidth = dims.width
dryHeight = dims.height
} else {
fmt.Fprintf(runtime.IO().ErrOut, "Note: unable to detect image dimensions from %s; provide both --width and --height to avoid failure at execution time\n", filePath)
}
}
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption, dryWidth, dryHeight)
d := common.NewDryRunAPI()
totalSteps := 4
@@ -188,6 +231,9 @@ var DocMediaInsert = common.Shortcut{
if runtime.Bool("from-clipboard") {
d.Set("upload_size_note", "clipboard size unknown; single-part vs multipart decision deferred to runtime")
}
if runtime.Bool("from-clipboard") && (widthChanged || heightChanged) && !(widthChanged && heightChanged) {
d.Set("dimension_note", "clipboard dimensions unknown; aspect-ratio calculation deferred to runtime")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -314,6 +360,42 @@ var DocMediaInsert = common.Shortcut{
// interface stays a true nil for the --file path. Passing a typed-nil
// *bytes.Reader here would make the downstream `if cfg.Content != nil`
// check incorrectly take the clipboard branch and crash on Read.
// Resolve display dimensions before upload to fail fast on unreadable images.
var finalWidth, finalHeight int
if mediaType == "image" {
userWidth := runtime.Int("width")
userHeight := runtime.Int("height")
widthChanged := runtime.Changed("width")
heightChanged := runtime.Changed("height")
if widthChanged && heightChanged {
finalWidth = userWidth
finalHeight = userHeight
} else if widthChanged || heightChanged {
var nativeW, nativeH int
var dimErr error
if clipboardContent != nil {
nativeW, nativeH, dimErr = detectImageDimensions(bytes.NewReader(clipboardContent))
} else {
f, openErr := runtime.FileIO().Open(filePath)
if openErr != nil {
return withRollbackWarning(output.ErrValidation(
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
}
nativeW, nativeH, dimErr = detectImageDimensions(f)
f.Close()
}
if dimErr != nil {
return withRollbackWarning(output.ErrValidation(
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
}
dims := computeMissingDimension(userWidth, userHeight, nativeW, nativeH)
finalWidth = dims.width
finalHeight = dims.height
fmt.Fprintf(runtime.IO().ErrOut, "Image dimensions: %dx%d (native: %dx%d)\n", finalWidth, finalHeight, nativeW, nativeH)
}
}
uploadCfg := UploadDocMediaFileConfig{
FilePath: filePath,
FileName: fileName,
@@ -337,16 +419,23 @@ var DocMediaInsert = common.Shortcut{
if _, err := runtime.CallAPI("PATCH",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)),
nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption)); err != nil {
nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption, finalWidth, finalHeight)); err != nil {
return withRollbackWarning(err)
}
runtime.Out(map[string]interface{}{
outData := map[string]interface{}{
"document_id": documentID,
"block_id": blockId,
"file_token": fileToken,
"type": mediaType,
}, nil)
}
if finalWidth > 0 {
outData["width"] = finalWidth
}
if finalHeight > 0 {
outData["height"] = finalHeight
}
runtime.Out(outData, nil)
return nil
},
}
@@ -453,7 +542,51 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
}
}
func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string) map[string]interface{} {
type imageDimensions struct {
width int
height int
}
func computeMissingDimension(userWidth, userHeight, nativeWidth, nativeHeight int) imageDimensions {
if nativeWidth <= 0 || nativeHeight <= 0 {
return imageDimensions{width: userWidth, height: userHeight}
}
if userWidth > 0 && userHeight == 0 {
return imageDimensions{
width: userWidth,
height: (userWidth*nativeHeight + nativeWidth/2) / nativeWidth,
}
}
if userHeight > 0 && userWidth == 0 {
return imageDimensions{
width: (userHeight*nativeWidth + nativeHeight/2) / nativeHeight,
height: userHeight,
}
}
return imageDimensions{width: userWidth, height: userHeight}
}
func detectImageDimensions(r io.Reader) (width, height int, err error) {
cfg, _, err := image.DecodeConfig(r)
if err != nil {
return 0, 0, err
}
return cfg.Width, cfg.Height, nil
}
func detectImageDimensionsFromPath(fio fileio.FileIO, filePath string) (int, int, error) {
if _, err := validate.SafeInputPath(filePath); err != nil {
return 0, 0, err
}
f, err := fio.Open(filePath)
if err != nil {
return 0, 0, err
}
defer f.Close()
return detectImageDimensions(f)
}
func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string, width, height int) map[string]interface{} {
request := map[string]interface{}{
"block_id": blockID,
}
@@ -465,6 +598,12 @@ func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption strin
replaceImage := map[string]interface{}{
"token": fileToken,
}
if width > 0 {
replaceImage["width"] = width
}
if height > 0 {
replaceImage["height"] = height
}
if alignVal, ok := alignMap[alignStr]; ok {
replaceImage["align"] = alignVal
}

View File

@@ -6,6 +6,7 @@ package doc
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
@@ -176,7 +177,7 @@ func TestBuildDeleteBlockDataUsesHalfOpenInterval(t *testing.T) {
func TestBuildBatchUpdateDataForImage(t *testing.T) {
t.Parallel()
got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text")
got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text", 0, 0)
want := map[string]interface{}{
"requests": []interface{}{
map[string]interface{}{
@@ -199,7 +200,7 @@ func TestBuildBatchUpdateDataForImage(t *testing.T) {
func TestBuildBatchUpdateDataForFile(t *testing.T) {
t.Parallel()
got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "")
got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "", 0, 0)
want := map[string]interface{}{
"requests": []interface{}{
map[string]interface{}{
@@ -215,6 +216,48 @@ func TestBuildBatchUpdateDataForFile(t *testing.T) {
}
}
func TestBuildBatchUpdateDataForImageWithWidthHeight(t *testing.T) {
t.Parallel()
got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text", 800, 447)
want := map[string]interface{}{
"requests": []interface{}{
map[string]interface{}{
"block_id": "blk_1",
"replace_image": map[string]interface{}{
"token": "file_tok",
"width": 800,
"height": 447,
"align": 2,
"caption": map[string]interface{}{"content": "caption text"},
},
},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildBatchUpdateData(image, 800, 447) = %#v, want %#v", got, want)
}
}
func TestBuildBatchUpdateDataForFileIgnoresWidthHeight(t *testing.T) {
t.Parallel()
got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "", 800, 600)
want := map[string]interface{}{
"requests": []interface{}{
map[string]interface{}{
"block_id": "blk_2",
"replace_file": map[string]interface{}{
"token": "file_tok",
},
},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildBatchUpdateData(file, 800, 600) = %#v, want %#v", got, want)
}
}
func TestExtractAppendTargetUsesRootChildrenCount(t *testing.T) {
t.Parallel()
@@ -669,10 +712,202 @@ func newMediaInsertValidateRuntime(t *testing.T, doc, mediaType, fileView string
return common.TestNewRuntimeContext(cmd, nil)
}
// Validate is the real user-facing contract for --file-view: unknown
// values must be rejected, and passing the flag alongside --type!=file
// must also be rejected. buildCreateBlockData tests alone cannot catch
// regressions here, so lock the guard logic down explicitly.
func newMediaInsertValidateRuntimeWithSize(t *testing.T, doc, mediaType string, width, height int, setWidth, setHeight bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "docs +media-insert"}
cmd.Flags().String("file", "", "")
cmd.Flags().Bool("from-clipboard", false, "")
cmd.Flags().String("doc", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("file-view", "", "")
cmd.Flags().Int("width", 0, "")
cmd.Flags().Int("height", 0, "")
cmd.Flags().String("selection-with-ellipsis", "", "")
cmd.Flags().Bool("before", false, "")
if err := cmd.Flags().Set("file", "dummy.bin"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("doc", doc); err != nil {
t.Fatalf("set --doc: %v", err)
}
if err := cmd.Flags().Set("type", mediaType); err != nil {
t.Fatalf("set --type: %v", err)
}
if setWidth {
if err := cmd.Flags().Set("width", fmt.Sprintf("%d", width)); err != nil {
t.Fatalf("set --width: %v", err)
}
}
if setHeight {
if err := cmd.Flags().Set("height", fmt.Sprintf("%d", height)); err != nil {
t.Fatalf("set --height: %v", err)
}
}
return common.TestNewRuntimeContext(cmd, nil)
}
func TestDocMediaInsertValidateWidthHeightOnlyForImage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mediaType string
width int
height int
setWidth bool
setHeight bool
wantErr string
}{
{
name: "width with file type is rejected",
mediaType: "file",
width: 800,
setWidth: true,
wantErr: "--width/--height only apply when --type=image",
},
{
name: "height with file type is rejected",
mediaType: "file",
height: 600,
setHeight: true,
wantErr: "--width/--height only apply when --type=image",
},
{
name: "explicit zero width is rejected",
mediaType: "image",
width: 0,
setWidth: true,
wantErr: "--width must be a positive integer",
},
{
name: "negative width is rejected",
mediaType: "image",
width: -1,
setWidth: true,
wantErr: "--width must be a positive integer",
},
{
name: "negative height is rejected",
mediaType: "image",
height: -5,
setHeight: true,
wantErr: "--height must be a positive integer",
},
{
name: "valid width with image type is accepted",
mediaType: "image",
width: 800,
setWidth: true,
},
{
name: "valid width and height with image type is accepted",
mediaType: "image",
width: 800,
height: 600,
setWidth: true,
setHeight: true,
},
}
for _, ttTemp := range tests {
tt := ttTemp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newMediaInsertValidateRuntimeWithSize(t, "doxcnValidateSize", tt.mediaType, tt.width, tt.height, tt.setWidth, tt.setHeight)
err := DocMediaInsert.Validate(context.Background(), rt)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("Validate() error = nil, want error containing %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tt.wantErr)
}
})
}
}
func TestDocMediaInsertValidateNoWidthHeightIsValid(t *testing.T) {
t.Parallel()
rt := newMediaInsertValidateRuntimeWithSize(t, "doxcnNoSize", "image", 0, 0, false, false)
err := DocMediaInsert.Validate(context.Background(), rt)
if err != nil {
t.Fatalf("Validate() unexpected error when neither --width nor --height passed: %v", err)
}
}
func TestAutoAspectRatioFromWidth(t *testing.T) {
t.Parallel()
// Native image: 1200x800 (3:2 ratio)
// User provides width=600 → expected height = 600 * 800 / 1200 = 400
got := computeMissingDimension(600, 0, 1200, 800)
wantWidth, wantHeight := 600, 400
if got.width != wantWidth || got.height != wantHeight {
t.Fatalf("computeMissingDimension(600, 0, 1200, 800) = (%d, %d), want (%d, %d)", got.width, got.height, wantWidth, wantHeight)
}
}
func TestAutoAspectRatioFromHeight(t *testing.T) {
t.Parallel()
// Native image: 1200x800 (3:2 ratio)
// User provides height=400 → expected width = 400 * 1200 / 800 = 600
got := computeMissingDimension(0, 400, 1200, 800)
wantWidth, wantHeight := 600, 400
if got.width != wantWidth || got.height != wantHeight {
t.Fatalf("computeMissingDimension(0, 400, 1200, 800) = (%d, %d), want (%d, %d)", got.width, got.height, wantWidth, wantHeight)
}
}
func TestComputeMissingDimensionBothProvided(t *testing.T) {
t.Parallel()
got := computeMissingDimension(800, 600, 1200, 900)
if got.width != 800 || got.height != 600 {
t.Fatalf("computeMissingDimension(800, 600, 1200, 900) = (%d, %d), want (800, 600)", got.width, got.height)
}
}
func TestComputeMissingDimensionNeitherProvided(t *testing.T) {
t.Parallel()
got := computeMissingDimension(0, 0, 1200, 900)
if got.width != 0 || got.height != 0 {
t.Fatalf("computeMissingDimension(0, 0, 1200, 900) = (%d, %d), want (0, 0)", got.width, got.height)
}
}
func TestComputeMissingDimensionZeroNativeWidth(t *testing.T) {
t.Parallel()
got := computeMissingDimension(600, 0, 0, 800)
if got.width != 600 || got.height != 0 {
t.Fatalf("computeMissingDimension(600, 0, 0, 800) = (%d, %d), want (600, 0)", got.width, got.height)
}
}
func TestComputeMissingDimensionZeroNativeHeight(t *testing.T) {
t.Parallel()
got := computeMissingDimension(0, 400, 1200, 0)
if got.width != 0 || got.height != 400 {
t.Fatalf("computeMissingDimension(0, 400, 1200, 0) = (%d, %d), want (0, 400)", got.width, got.height)
}
}
func TestComputeMissingDimensionRounding(t *testing.T) {
t.Parallel()
got := computeMissingDimension(999, 0, 1000, 333)
want := (999*333 + 500) / 1000
if got.height != want {
t.Fatalf("computeMissingDimension(999, 0, 1000, 333).height = %d, want %d (rounded)", got.height, want)
}
}
func TestDocMediaInsertValidateFileView(t *testing.T) {
t.Parallel()

View File

@@ -18,8 +18,19 @@ const docsServiceHelpDefault = `Document and content operations.`
const docsServiceHelpV2 = `Document and content operations (v2).`
var docsVersionSelectionTips = []string{
"Agent version rule: use --api-version v2 only when the installed lark-doc skill explicitly instructs docs +create, docs +fetch, or docs +update to use v2; otherwise use the default v1 flags.",
"Do not mix versions: if the skill does not mention v2, follow its legacy v1 examples and flags.",
"Docs v1 is deprecated and will be removed soon. Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
"After confirming lark-doc is v2, follow that skill's examples and use `--api-version v2` with docs +create, docs +fetch, and docs +update.",
}
var docsV2VersionSelectionTips = []string{
"Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
}
func docsTipsForVersion(apiVersion string) []string {
if apiVersion == "v2" {
return docsV2VersionSelectionTips
}
return docsVersionSelectionTips
}
// Shortcuts returns all docs shortcuts.
@@ -38,8 +49,7 @@ func Shortcuts() []common.Shortcut {
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
// The shortcut-level help remains compatible with legacy v1 skills; this parent
// help gives agents enough context to choose v2 only when their installed skill
// explicitly asks for `--api-version v2`.
// help switches docs guidance to match the selected API version.
func ConfigureServiceHelp(cmd *cobra.Command) {
if cmd == nil {
return
@@ -75,7 +85,7 @@ func ConfigureServiceHelp(cmd *cobra.Command) {
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range docsVersionSelectionTips {
for _, tip := range docsTipsForVersion(apiVersion) {
fmt.Fprintf(out, " • %s\n", tip)
}
})

View File

@@ -6,6 +6,7 @@ package doc
import (
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -29,6 +30,7 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
f.Hidden = fv != ver
}
})
cmdutil.SetTips(cmd, docsTipsForVersion(ver))
origHelp(cmd, args)
})
}
@@ -37,6 +39,6 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
// path is used.
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
fmt.Fprintf(runtime.IO().ErrOut,
"[deprecated] docs %s with v1 API is deprecated and will be removed in a future release.\n",
shortcut)
"[deprecated] docs %s is using the v1 API. %s\n",
shortcut, docsV2VersionSelectionTips[0])
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
)
func TestWarnDeprecatedV1SuggestsSkillUpdate(t *testing.T) {
for _, shortcut := range []string{"+create", "+fetch", "+update"} {
t.Run(shortcut, func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
warnDeprecatedV1(&common.RuntimeContext{Factory: f}, shortcut)
got := stderr.String()
for _, want := range []string{
"[deprecated] docs " + shortcut + " is using the v1 API.",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
} {
if !strings.Contains(got, want) {
t.Fatalf("warning missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "will be removed in a future release") {
t.Fatalf("warning should not include removal-only guidance:\n%s", got)
}
})
}
}

View File

@@ -15,6 +15,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
@@ -625,6 +626,94 @@ func TestChooseRemoteFileSortsByParsedTimes(t *testing.T) {
}
}
// TestChooseRemoteFileSortsMixedUnitEpochsByActualTime verifies duplicate
// resolution compares actual timestamps rather than raw integer magnitudes when
// Drive mixes second- and millisecond-resolution epoch strings.
func TestChooseRemoteFileSortsMixedUnitEpochsByActualTime(t *testing.T) {
files := []driveRemoteEntry{
{FileToken: "token_seconds", CreatedTime: "1715594881", ModifiedTime: "1715594881"},
{FileToken: "token_millis", CreatedTime: "1715594880123", ModifiedTime: "1715594880123"},
}
gotNewest, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
if err != nil {
t.Fatalf("chooseRemoteFile newest: %v", err)
}
if gotNewest.FileToken != "token_seconds" {
t.Fatalf("newest token = %q, want token_seconds", gotNewest.FileToken)
}
gotOldest, err := chooseRemoteFile(files, driveDuplicateRemoteOldest)
if err != nil {
t.Fatalf("chooseRemoteFile oldest: %v", err)
}
if gotOldest.FileToken != "token_millis" {
t.Fatalf("oldest token = %q, want token_millis", gotOldest.FileToken)
}
}
// TestDrivePushDeleteRemoteKeepsActualNewestDuplicateAcrossMixedEpochUnits
// proves the duplicate selector and delete pass agree on the true newest file
// even when remote timestamps use mixed epoch units.
func TestDrivePushDeleteRemoteKeepsActualNewestDuplicateAcrossMixedEpochUnits(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1715594880123", "modified_time": "1715594880123"},
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "1715594881", "modified_time": "1715594881"},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "dup-new-token",
"version": "v7",
},
},
}
reg.Register(uploadStub)
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(deleteStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
}
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
if deleteStub.CapturedHeaders == nil {
t.Fatal("DELETE for older mixed-unit duplicate sibling was never issued")
}
reg.Verify(t)
}
func TestChooseRemoteFileFallsBackToFileTokenOnTimeParseFailure(t *testing.T) {
files := []driveRemoteEntry{
{FileToken: "token_a", CreatedTime: "bad", ModifiedTime: "bad"},
@@ -646,6 +735,46 @@ func TestChooseRemoteFileRejectsEmptyCandidates(t *testing.T) {
}
}
func TestCompareDriveRemoteModifiedToLocalSupportsSecondAndMillisecondEpochs(t *testing.T) {
t.Run("second resolution truncates local mtime", func(t *testing.T) {
cmp, ok := compareDriveRemoteModifiedToLocal("100", time.Unix(100, 900*int64(time.Millisecond)))
if !ok {
t.Fatal("expected second-resolution timestamp to parse")
}
if cmp != 0 {
t.Fatalf("cmp = %d, want 0 when local only differs below second resolution", cmp)
}
})
t.Run("millisecond resolution stays precise", func(t *testing.T) {
const remoteMillis = int64(1715594880123)
cmp, ok := compareDriveRemoteModifiedToLocal(strconv.FormatInt(remoteMillis, 10), time.UnixMilli(remoteMillis))
if !ok {
t.Fatal("expected millisecond-resolution timestamp to parse")
}
if cmp != 0 {
t.Fatalf("cmp = %d, want 0 for equal millisecond timestamps", cmp)
}
})
t.Run("microsecond resolution stays precise", func(t *testing.T) {
const remoteMicros = int64(1715594880123456)
cmp, ok := compareDriveRemoteModifiedToLocal(strconv.FormatInt(remoteMicros, 10), time.UnixMicro(remoteMicros))
if !ok {
t.Fatal("expected microsecond-resolution timestamp to parse")
}
if cmp != 0 {
t.Fatalf("cmp = %d, want 0 for equal microsecond timestamps", cmp)
}
})
t.Run("invalid timestamp is rejected", func(t *testing.T) {
if _, ok := compareDriveRemoteModifiedToLocal("not-a-time", time.Now()); ok {
t.Fatal("expected invalid remote timestamp to be rejected")
}
})
}
func TestDrivePullRemoteViewsRejectsUnknownStrategy(t *testing.T) {
_, _, err := drivePullRemoteViews([]driveRemoteEntry{
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDFirst},

View File

@@ -228,6 +228,206 @@ func TestDriveUploadLargeFileToWikiUsesMultipart(t *testing.T) {
}
}
func TestDriveUploadLargeFileOverwriteUsesMultipart(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-large-overwrite-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
prepareStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"upload_id": "test-upload-id",
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
"block_num": float64(2),
},
},
}
reg.Register(prepareStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_finish",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "file_multipart_overwrite_token",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
fh, err := os.Create("large.bin")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
err = mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "large.bin",
"--file-token", "box_existing_large_upload",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err)
}
body := decodeCapturedJSONBody(t, prepareStub)
if got := body["file_token"]; got != "box_existing_large_upload" {
t.Fatalf("file_token = %#v, want %q", got, "box_existing_large_upload")
}
}
func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinish(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-large-overwrite-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"upload_id": "test-upload-id",
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
"block_num": float64(1),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_finish",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "file_multipart_overwrite_version_token",
"version": "v44",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
fh, err := os.Create("large.bin")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
err = mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "large.bin",
"--file-token", "box_existing_large_upload",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["version"]; got != "v44" {
t.Fatalf("data.version = %#v, want %q", got, "v44")
}
}
func TestDriveUploadLargeFileOverwriteReturnsVersionFromUploadFinishAlias(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-large-overwrite-data-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"upload_id": "test-upload-id",
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
"block_num": float64(1),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_finish",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "file_multipart_overwrite_alias_token",
"data_version": "v45",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
fh, err := os.Create("large.bin")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
err = mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "large.bin",
"--file-token", "box_existing_large_upload",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected multipart overwrite upload to succeed, got error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["version"]; got != "v45" {
t.Fatalf("data.version = %#v, want %q", got, "v45")
}
}
func TestDriveUploadSmallFile(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-small-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -267,6 +467,93 @@ func TestDriveUploadSmallFile(t *testing.T) {
}
}
func TestDriveUploadSmallFileOverwriteUsesFileToken(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-small-overwrite-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "file_small_overwrite_token",
"version": "v42",
},
},
}
reg.Register(stub)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "small.bin",
"--file-token", "box_existing_small_upload",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected small overwrite upload to succeed, got error: %v", err)
}
body := decodeDriveMultipartBody(t, stub)
if got := body.Fields["file_token"]; got != "box_existing_small_upload" {
t.Fatalf("file_token = %q, want %q", got, "box_existing_small_upload")
}
data := decodeDriveEnvelope(t, stdout)
if got := data["version"]; got != "v42" {
t.Fatalf("data.version = %#v, want %q", got, "v42")
}
}
func TestDriveUploadReturnsVersionFromDataVersionAlias(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-small-data-version-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "file_small_alias_token",
"data_version": "v43",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "small.bin",
"--file-token", "box_existing_alias_upload",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected overwrite upload to succeed, got error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["version"]; got != "v43" {
t.Fatalf("data.version = %#v, want %q", got, "v43")
}
}
func TestDriveUploadSmallFileToWiki(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-small-wiki-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -767,6 +1054,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
@@ -812,6 +1100,7 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
@@ -821,6 +1110,9 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
if err := cmd.Flags().Set("folder-token", " fld_upload_target "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
if err := cmd.Flags().Set("file-token", " box_upload_target "); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("wiki-token", " wikcn_upload_target "); err != nil {
t.Fatalf("set --wiki-token: %v", err)
}
@@ -839,11 +1131,108 @@ func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
if got.FolderToken != "fld_upload_target" {
t.Fatalf("FolderToken = %q, want trimmed token", got.FolderToken)
}
if got.FileToken != "box_upload_target" {
t.Fatalf("FileToken = %q, want trimmed token", got.FileToken)
}
if got.WikiToken != "wikcn_upload_target" {
t.Fatalf("WikiToken = %q, want trimmed token", got.WikiToken)
}
}
func TestDriveUploadDryRunIncludesFileToken(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
if err := cmd.Flags().Set("file", "./report.pdf"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("file-token", "boxcn_dryrun_overwrite"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveUpload.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
}
}
func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("as", "", "")
if err := cmd.Flags().Set("file", "./report.pdf"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("file-token", "boxcn_dryrun_overwrite"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("as", "bot"); err != nil {
t.Fatalf("set --as: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveUpload.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Desc string `json:"desc"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
}
if strings.Contains(got.API[0].Desc, "grant the current CLI user full_access") {
t.Fatalf("dry-run desc should skip permission-grant hint for overwrite, got %q", got.API[0].Desc)
}
}
func TestDriveUploadTargetLabel(t *testing.T) {
t.Parallel()
@@ -901,6 +1290,7 @@ func TestDriveUploadValidateRejectsConflictingTargets(t *testing.T) {
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
@@ -923,6 +1313,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
@@ -940,11 +1331,35 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
}
}
func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
if err := cmd.Flags().Set("file", "report.pdf"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("file-token", " "); err != nil {
t.Fatalf("set --file-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
err := DriveUpload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--file-token cannot be empty") {
t.Fatalf("Validate() error = %v, want empty file-token error", err)
}
}
func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")
@@ -983,6 +1398,12 @@ func TestDriveUploadValidateRejectsInvalidTargetTokens(t *testing.T) {
value: "wikcn_bad#fragment",
wantErr: "--wiki-token contains invalid characters",
},
{
name: "file token",
flag: "file-token",
value: "box_bad?query=true",
wantErr: "--file-token contains invalid characters",
},
}
for _, tt := range tests {
@@ -991,6 +1412,7 @@ func TestDriveUploadValidateRejectsInvalidTargetTokens(t *testing.T) {
cmd := &cobra.Command{Use: "drive +upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("wiki-token", "", "")
cmd.Flags().String("name", "", "")

View File

@@ -75,6 +75,48 @@ func TestDriveUploadBotAutoGrantSuccess(t *testing.T) {
}
}
func TestDriveUploadBotOverwriteSkipsPermissionGrant(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"file_token": "file_uploaded",
"version": "v2",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("report.pdf", []byte("pdf"), 0o644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "report.pdf",
"--file-token", "file_uploaded",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant for overwrite output: %#v", data)
}
if got := data["version"]; got != "v2" {
t.Fatalf("version = %#v, want %q", got, "v2")
}
}
func TestDriveImportBotAutoGrantSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
registerDriveBotTokenStub(reg)

View File

@@ -11,6 +11,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -20,8 +21,18 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
var drivePullChtimes = drivePullApplyChtimes
// drivePullApplyChtimes is a tiny indirection that keeps the production path on
// os.Chtimes while still letting tests inject mtime failures without requiring a
// custom filesystem implementation.
func drivePullApplyChtimes(path string, atime, mtime time.Time) error {
return os.Chtimes(path, atime, mtime) //nolint:forbidigo // FileIO exposes no mtime mutation API yet; callers resolve and bound the path first.
}
const (
drivePullIfExistsOverwrite = "overwrite"
drivePullIfExistsSmart = "smart"
drivePullIfExistsSkip = "skip"
)
@@ -37,6 +48,7 @@ type drivePullTarget struct {
DownloadToken string
ItemFileToken string
ItemSourceID string
ModifiedTime string
}
// DrivePull performs a one-way file-level mirror from a Drive folder onto
@@ -60,7 +72,7 @@ var DrivePull = common.Shortcut{
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
{Name: "if-exists", Desc: "policy when a local file already exists (skip = never touch existing files; smart = skip when local mtime is already up to date; overwrite = always replace)", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSmart, drivePullIfExistsSkip}},
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteRename, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
@@ -68,6 +80,7 @@ var DrivePull = common.Shortcut{
Tips: []string{
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
"For repeat syncs, --if-exists=smart is the recommended best-effort incremental mode: it compares local mtime with Drive modified_time and skips downloads when the local copy is already up to date.",
"Duplicate remote rel_path conflicts fail by default. Use --on-duplicate-remote=rename to download duplicate files with stable hashed suffixes.",
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
},
@@ -202,14 +215,14 @@ var DrivePull = common.Shortcut{
downloadFailed++
continue
}
if ifExists == drivePullIfExistsSkip {
if ifExists == drivePullIfExistsSkip || drivePullShouldSkipSmart(target, targetFile, ifExists, runtime) {
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "skipped"})
skipped++
continue
}
}
if err := drivePullDownload(ctx, runtime, downloadToken, target); err != nil {
if err := drivePullDownload(ctx, runtime, downloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
failed++
downloadFailed++
@@ -305,7 +318,9 @@ var DrivePull = common.Shortcut{
},
}
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
// drivePullDownload streams one Drive file into the local mirror target and
// then best-effort aligns the local mtime to Drive's modified_time.
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
@@ -320,9 +335,53 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file
}, resp.Body); err != nil {
return common.WrapSaveErrorByCategory(err, "io")
}
if err := drivePullApplyRemoteModifiedTime(target, remoteModifiedTime, runtime); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Downloaded %s but could not preserve remote modified_time: %s\n", target, err)
}
return nil
}
// drivePullApplyRemoteModifiedTime preserves Drive's modified_time on a local
// file when the remote timestamp is parseable and the target path is safe.
func drivePullApplyRemoteModifiedTime(target, remoteModifiedTime string, runtime *common.RuntimeContext) error {
remoteTime, _, ok := parseDriveEpoch(remoteModifiedTime)
if !ok {
return nil
}
resolved, err := runtime.FileIO().ResolvePath(target)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
if err := drivePullChtimes(resolved, remoteTime, remoteTime); err != nil {
return output.Errorf(output.ExitInternal, "io", "cannot preserve remote modified_time on local file: %s", err)
}
return nil
}
func drivePullShouldSkipSmart(target string, remoteFile drivePullTarget, ifExists string, runtime *common.RuntimeContext) bool {
if ifExists != drivePullIfExistsSmart {
return false
}
if remoteFile.ModifiedTime == "" {
return false
}
resolved, err := runtime.FileIO().ResolvePath(target)
if err != nil {
return false
}
info, err := os.Stat(resolved) //nolint:forbidigo // FileIO exposes no ModTime-capable Stat; ResolvePath already bounded the path.
if err != nil {
return false
}
cmp, ok := compareDriveRemoteModifiedToLocal(remoteFile.ModifiedTime, info.ModTime())
if !ok {
return false
}
// Local is already at least as new as the remote file, so another
// download would be redundant.
return cmp <= 0
}
func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]drivePullTarget, map[string]struct{}, error) {
remoteFiles := make(map[string]drivePullTarget, len(entries))
remotePaths := make(map[string]struct{}, len(entries))
@@ -346,7 +405,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
for _, rel := range relPaths {
files := fileGroups[rel]
if len(files) == 1 {
remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken}
remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken, ModifiedTime: files[0].ModifiedTime}
remotePaths[rel] = struct{}{}
continue
}
@@ -366,6 +425,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
remoteFiles[targetRel] = drivePullTarget{
DownloadToken: file.FileToken,
ItemSourceID: stableTokenIdentifier(file.FileToken),
ModifiedTime: file.ModifiedTime,
}
remotePaths[targetRel] = struct{}{}
}
@@ -374,7 +434,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
if err != nil {
return nil, nil, err
}
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken}
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken, ModifiedTime: chosen.ModifiedTime}
remotePaths[rel] = struct{}{}
default:
return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)

View File

@@ -4,17 +4,23 @@
package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// TestDrivePullDownloadsAndCreatesParents verifies the happy path: a remote
@@ -151,6 +157,322 @@ func TestDrivePullSkipsExistingWhenSkipPolicy(t *testing.T) {
mustReadFile(t, filepath.Join("local", "keep.txt"), "local-original")
}
// TestDrivePullSkipsExistingWhenSmartPolicyAndLocalIsUpToDate verifies the
// smart fast path for Drive → local mirrors: when the local copy is already
// at least as new as the remote file, +pull skips the download.
func TestDrivePullSkipsExistingWhenSmartPolicyAndLocalIsUpToDate(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(200, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "100"},
},
"has_more": false,
},
},
})
// Intentionally NO download stub: smart mode should skip the transfer.
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"skipped": 1`) {
t.Errorf("expected skipped=1, got: %s", out)
}
if !strings.Contains(out, `"downloaded": 0`) {
t.Errorf("expected downloaded=0, got: %s", out)
}
mustReadFile(t, localPath, "hello")
}
// TestDrivePullDownloadsWhenSmartPolicyAndRemoteIsNewer verifies the smart
// policy still downloads when the remote file is newer than the local copy.
func TestDrivePullDownloadsWhenSmartPolicyAndRemoteIsNewer(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(100, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_keep/download",
Status: 200,
Body: []byte("WORLD"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"downloaded": 1`) {
t.Errorf("expected downloaded=1, got: %s", out)
}
mustReadFile(t, localPath, "WORLD")
info, err := os.Stat(localPath)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got, want := info.ModTime(), time.Unix(200, 0); !got.Equal(want) {
t.Fatalf("local mtime = %v, want %v", got, want)
}
}
// TestDrivePullTreatsModifiedTimePreservationFailureAsNotice verifies a local
// write that succeeds but cannot preserve remote modified_time still reports a
// successful download and only emits an operator-facing notice on stderr.
func TestDrivePullTreatsModifiedTimePreservationFailureAsNotice(t *testing.T) {
f, stdout, stderrBuf, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
prevChtimes := drivePullChtimes
drivePullChtimes = func(string, time.Time, time.Time) error {
return fmt.Errorf("mtime mutation unsupported")
}
t.Cleanup(func() {
drivePullChtimes = prevChtimes
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_keep/download",
Status: 200,
Body: []byte("WORLD"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--delete-local",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderrBuf.String())
}
out := stdout.String()
if !strings.Contains(out, `"downloaded": 1`) {
t.Errorf("expected downloaded=1, got: %s", out)
}
if !strings.Contains(out, `"failed": 0`) {
t.Errorf("expected failed=0, got: %s", out)
}
mustReadFile(t, filepath.Join("local", "keep.txt"), "WORLD")
if !strings.Contains(stderrBuf.String(), "could not preserve remote modified_time") {
t.Errorf("expected stderr notice about modified_time preservation failure, got: %s", stderrBuf.String())
}
reg.Verify(t)
}
func TestDrivePullShouldSkipSmartFallsBackWhenMetadataCannotBeTrusted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(100, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %v", err)
}
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
for _, tt := range []struct {
name string
ifExists string
remoteFile drivePullTarget
}{
{
name: "non-smart policy",
ifExists: drivePullIfExistsOverwrite,
remoteFile: drivePullTarget{ModifiedTime: "100"},
},
{
name: "missing remote timestamp",
ifExists: drivePullIfExistsSmart,
remoteFile: drivePullTarget{ModifiedTime: ""},
},
{
name: "invalid remote timestamp",
ifExists: drivePullIfExistsSmart,
remoteFile: drivePullTarget{ModifiedTime: "not-a-time"},
},
} {
t.Run(tt.name, func(t *testing.T) {
if got := drivePullShouldSkipSmart(localPath, tt.remoteFile, tt.ifExists, runtime); got {
t.Fatalf("drivePullShouldSkipSmart() = true, want false for %s", tt.name)
}
})
}
}
func TestDrivePullShouldSkipSmartFallsBackWhenPathCannotBeResolved(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
if got := drivePullShouldSkipSmart("../escape.txt", drivePullTarget{ModifiedTime: "100"}, drivePullIfExistsSmart, runtime); got {
t.Fatal("drivePullShouldSkipSmart() = true, want false when ResolvePath rejects the target")
}
}
func TestDrivePullShouldSkipSmartFallsBackWhenLocalFileDisappeared(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
if got := drivePullShouldSkipSmart(filepath.Join("local", "missing.txt"), drivePullTarget{ModifiedTime: "100"}, drivePullIfExistsSmart, runtime); got {
t.Fatal("drivePullShouldSkipSmart() = true, want false when os.Stat cannot find the local file")
}
}
func TestDrivePullSkipsWhenSmartIgnoresRemoteSize(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(200, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 999, "modified_time": "100"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"skipped": 1`) {
t.Errorf("expected skipped=1, got: %s", out)
}
if !strings.Contains(out, `"downloaded": 0`) {
t.Errorf("expected downloaded=0, got: %s", out)
}
mustReadFile(t, localPath, "hello")
}
// TestDrivePullSurfacesDirectoryFileMirrorConflict pins the contract
// for the case where Drive ships a regular file at a rel_path that is
// already a directory locally. SafeOutputPath would refuse to overwrite

View File

@@ -15,6 +15,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -25,6 +26,7 @@ import (
const (
drivePushIfExistsOverwrite = "overwrite"
drivePushIfExistsSmart = "smart"
drivePushIfExistsSkip = "skip"
)
@@ -91,7 +93,7 @@ var DrivePush = common.Shortcut{
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (skip = never touch existing remote files; smart = skip when remote modified_time already matches or is newer, otherwise fall through to overwrite semantics; overwrite = always replace)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSmart, drivePushIfExistsSkip}},
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
@@ -99,8 +101,9 @@ var DrivePush = common.Shortcut{
Tips: []string{
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
"For repeat syncs, --if-exists=smart is a best-effort incremental mode: it compares local mtime with Drive modified_time and skips uploads when the remote copy is already up to date; otherwise it falls through to the same overwrite path as --if-exists=overwrite.",
"Duplicate remote rel_path conflicts fail by default before upload, overwrite, or delete. Use --on-duplicate-remote=newest|oldest only when the conflict is duplicate files and you explicitly want to target one.",
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero. The same caveat applies when --if-exists=smart decides the remote file is older and falls through to overwrite.",
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
@@ -151,7 +154,7 @@ var DrivePush = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, overwrite (when --if-exists=overwrite) or skip existing, and (when --delete-remote --yes is set) delete Drive files absent locally.").
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, skip existing, skip up-to-date files when --if-exists=smart, overwrite when --if-exists=overwrite, and (when --delete-remote --yes is set) delete Drive files absent locally.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
@@ -267,12 +270,19 @@ var DrivePush = common.Shortcut{
localFile := localFiles[rel]
if entry, ok := remoteFiles[rel]; ok {
if ifExists == drivePushIfExistsSkip {
if drivePushShouldSkipExisting(localFile, entry, ifExists) {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size})
skipped++
continue
}
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken)
parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache)
if parentErr != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "failed", SizeBytes: localFile.Size, Error: parentErr.Error()})
failed++
uploadFailed = true
continue
}
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken)
if upErr != nil {
// Token contract on overwrite failure: an in-place
// overwrite preserves the file's token, so the
@@ -394,6 +404,7 @@ type drivePushLocalFile struct {
OpenPath string
FileName string
Size int64
ModTime time.Time
}
// drivePushWalkLocal walks the canonical absolute root produced by
@@ -450,6 +461,7 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
OpenPath: relToCwd,
FileName: filepath.Base(rel),
Size: info.Size(),
ModTime: info.ModTime(),
}
return nil
})
@@ -473,6 +485,30 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
return files, dirs, nil
}
func drivePushShouldSkipExisting(localFile drivePushLocalFile, remoteFile driveRemoteEntry, ifExists string) bool {
switch ifExists {
case drivePushIfExistsSkip:
return true
case drivePushIfExistsSmart:
return drivePushShouldSkipSmart(localFile, remoteFile)
default:
return false
}
}
func drivePushShouldSkipSmart(localFile drivePushLocalFile, remoteFile driveRemoteEntry) bool {
cmp, ok := compareDriveRemoteModifiedToLocal(remoteFile.ModifiedTime, localFile.ModTime)
if !ok {
// Smart mode is an optimization. If the timestamp is missing or
// malformed, fall back to the safe transfer path instead of silently
// skipping an update we could not compare.
return false
}
// Remote is already at least as new as the local file, so another
// upload would be redundant.
return cmp >= 0
}
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
@@ -551,6 +587,10 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext,
return token, nil
}
func drivePushEnsureParentToken(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relPath string, folderCache map[string]string) (string, error) {
return drivePushEnsureFolder(ctx, runtime, rootFolderToken, drivePushParentRel(relPath), folderCache)
}
// drivePushUploadFile uploads (or overwrites) a single local file. When
// existingToken is non-empty, the request adds the file_token form field to
// trigger overwrite-with-version semantics on the backend; the response is

View File

@@ -12,6 +12,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
@@ -324,6 +325,203 @@ func TestDrivePushSkipsWhenIfExistsSkip(t *testing.T) {
// would 404 against the registry and the run would have errored above.
}
// TestDrivePushSkipsWhenIfExistsSmartAndRemoteIsUpToDate verifies the smart
// fast path for local → Drive mirrors: when the remote copy is already at
// least as new as the local file, +push skips the upload.
func TestDrivePushSkipsWhenIfExistsSmartAndRemoteIsUpToDate(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(100, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
},
"has_more": false,
},
},
})
// Intentionally NO upload_all stub: smart mode should skip the transfer.
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"skipped": 1`) {
t.Errorf("expected skipped=1, got: %s", out)
}
if !strings.Contains(out, `"uploaded": 0`) {
t.Errorf("expected uploaded=0, got: %s", out)
}
}
// TestDrivePushOverwritesWhenIfExistsSmartAndLocalIsNewer verifies the smart
// path still uploads when the local file is newer than the remote one.
func TestDrivePushOverwritesWhenIfExistsSmartAndLocalIsNewer(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(200, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_keep_old", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "100"},
},
"has_more": false,
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "tok_keep_new", "version": "v43"},
},
}
reg.Register(uploadStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"uploaded": 1`) {
t.Errorf("expected uploaded=1, got: %s", out)
}
if !strings.Contains(out, `"action": "overwritten"`) {
t.Errorf("expected overwritten action, got: %s", out)
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != "tok_keep_old" {
t.Fatalf("upload_all form file_token = %q, want tok_keep_old", got)
}
}
func TestDrivePushShouldSkipSmartFallsBackWhenMetadataCannotBeTrusted(t *testing.T) {
t.Parallel()
localFile := drivePushLocalFile{
Size: 5,
ModTime: time.Unix(100, 500*int64(time.Millisecond)),
}
for _, tt := range []struct {
name string
remoteFile driveRemoteEntry
}{
{
name: "invalid remote timestamp",
remoteFile: driveRemoteEntry{ModifiedTime: "not-a-time"},
},
} {
t.Run(tt.name, func(t *testing.T) {
if got := drivePushShouldSkipSmart(localFile, tt.remoteFile); got {
t.Fatalf("drivePushShouldSkipSmart() = true, want false for %s", tt.name)
}
})
}
}
func TestDrivePushSkipsWhenSmartIgnoresRemoteSize(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "keep.txt")
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
localMTime := time.Unix(100, 500*int64(time.Millisecond))
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
t.Fatalf("Chtimes: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 999, "modified_time": "200"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "smart",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"skipped": 1`) {
t.Errorf("expected skipped=1, got: %s", out)
}
if !strings.Contains(out, `"uploaded": 0`) {
t.Errorf("expected uploaded=0, got: %s", out)
}
}
// TestDrivePushDeleteRemoteRequiresYes locks in the upfront safety guard:
// --delete-remote without --yes must be refused before any list / upload
// happens, so a stray flag never silently deletes anything.
@@ -1098,6 +1296,130 @@ func TestDrivePushReusesExistingRemoteFolder(t *testing.T) {
}
}
// TestDrivePushOverwriteNestedFileUsesParentFolderToken verifies that
// overwriting an existing nested remote file keeps parent_node aligned with
// the file's actual parent folder instead of the root folder token.
func TestDrivePushOverwriteNestedFileUsesParentFolderToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "sub", "keep.txt"), []byte("local"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "fld_existing_sub", "name": "sub", "type": "folder"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=fld_existing_sub",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_keep_nested", "name": "keep.txt", "type": "file"},
},
"has_more": false,
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "tok_keep_nested",
"version": "v2",
},
},
}
reg.Register(uploadStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != "tok_keep_nested" {
t.Fatalf("upload_all file_token = %q, want tok_keep_nested", got)
}
if got := body.Fields["parent_node"]; got != "fld_existing_sub" {
t.Fatalf("upload_all parent_node = %q, want fld_existing_sub", got)
}
}
func TestDrivePushOverwriteNestedFileReportsParentEnsureFailure(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "sub", "keep.txt"), []byte("local"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_keep_nested", "name": "sub/keep.txt", "type": "file"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 9999,
"msg": "create parent failed",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"action": "failed"`) || !strings.Contains(stdout.String(), "create parent failed") {
t.Fatalf("expected failed item with create_folder error, got: %s", stdout.String())
}
}
// TestDrivePushMirrorsEmptyDirectories confirms the gap codex review
// flagged: a local directory with no files inside must still surface on
// Drive as a created sub-folder, not be silently dropped because the

View File

@@ -13,6 +13,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -26,8 +27,24 @@ type driveStatusEntry struct {
FileToken string `json:"file_token,omitempty"`
}
type driveStatusLocalFile struct {
PathToCwd string
ModTime time.Time
}
type driveStatusRemoteFile struct {
FileToken string
ModifiedTime string
}
const (
driveStatusDetectionExact = "exact"
driveStatusDetectionQuick = "quick"
)
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
// four buckets (new_local, new_remote, modified, unchanged) either by exact
// SHA-256 hash (default) or by a quick modified_time comparison (--quick).
//
// Only Drive entries with type=file are compared; online docs (docx, sheet,
// bitable, mindnote, slides) and shortcuts are skipped because there is no
@@ -37,19 +54,22 @@ type driveStatusEntry struct {
// path that resolves outside cwd, which keeps the local side bounded to the
// caller's working directory.
var DriveStatus = common.Shortcut{
Service: "drive",
Command: "+status",
Description: "Compare a local directory with a Drive folder by content hash",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
AuthTypes: []string{"user", "bot"},
Service: "drive",
Command: "+status",
Description: "Compare a local directory with a Drive folder by exact hash or quick modified_time",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly"},
ConditionalScopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "Drive folder token", Required: true},
{Name: "quick", Type: "bool", Desc: "compare modified_time only and skip remote downloads for files present on both sides"},
},
Tips: []string{
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.",
"Default detection=exact downloads files present on both sides and SHA-256 hashes them in memory; expect noticeable I/O on large folders.",
"Pass --quick for the recommended fast preflight mode: it compares local mtime with Drive modified_time, skips remote downloads, and reports detection=quick as a best-effort diff.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
@@ -77,17 +97,37 @@ var DriveStatus = common.Shortcut{
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
// Conditional scope pre-check: quick mode only compares local mtime with
// Drive modified_time, so it must not be blocked on the download grant.
// Exact mode hashes remote bytes, which requires drive:file:download. Do
// the stricter check here once we know which execution path the flags
// selected. EnsureScopes is a silent no-op when scope metadata is
// unavailable, so environments without token scope introspection still
// proceed and rely on the API-level missing_scope error if needed.
if !runtime.Bool("quick") {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
desc := "Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256."
if runtime.Bool("quick") {
desc = "Walk --local-dir, recursively list --folder-token, and compare local mtime with Drive modified_time for files present on both sides without downloading remote bytes."
}
return common.NewDryRunAPI().
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
Desc(desc).
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
detection := driveStatusDetectionExact
if runtime.Bool("quick") {
detection = driveStatusDetectionQuick
}
// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
@@ -112,7 +152,7 @@ var DriveStatus = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical)
if err != nil {
return err
}
@@ -130,30 +170,42 @@ var DriveStatus = common.Shortcut{
// hashable bytes and are intentionally absent from the diff
// view (a docx living next to a same-named local file is a
// known no-op).
remoteFiles := make(map[string]string, len(entries))
remoteFiles := make(map[string]driveStatusRemoteFile, len(entries))
for _, entry := range entries {
if entry.Type == driveTypeFile {
remoteFiles[entry.RelPath] = entry.FileToken
remoteFiles[entry.RelPath] = driveStatusRemoteFile{FileToken: entry.FileToken, ModifiedTime: entry.ModifiedTime}
}
}
paths := mergeStatusPaths(localHashes, remoteFiles)
paths := mergeStatusPaths(localFiles, remoteFiles)
var newLocal, newRemote, modified, unchanged []driveStatusEntry
for _, relPath := range paths {
localHash, hasLocal := localHashes[relPath]
remoteToken, hasRemote := remoteFiles[relPath]
localFile, hasLocal := localFiles[relPath]
remoteFile, hasRemote := remoteFiles[relPath]
switch {
case hasLocal && !hasRemote:
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
case !hasLocal && hasRemote:
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken})
default:
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}
if detection == driveStatusDetectionQuick {
if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
continue
}
localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd)
if err != nil {
return err
}
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken)
if err != nil {
return err
}
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
if localHash == remoteHash {
unchanged = append(unchanged, entry)
} else {
@@ -163,6 +215,7 @@ var DriveStatus = common.Shortcut{
}
runtime.Out(map[string]interface{}{
"detection": detection,
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
@@ -180,8 +233,8 @@ var DriveStatus = common.Shortcut{
// hit, we report rel_path relative to root for the JSON output, and
// convert the absolute path to a cwd-relative form so FileIO.Open's
// SafeInputPath check (which rejects absolute paths) still applies.
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
files := make(map[string]string)
func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalFile, error) {
files := make(map[string]driveStatusLocalFile)
// FileIO has no walker today and shortcuts can't import internal/vfs.
// The walk root is the canonical absolute path returned by
// validate.SafeInputPath, so it is no longer a symlink itself, and
@@ -202,11 +255,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
if err != nil {
return err
}
sum, err := hashLocalForStatus(runtime, relToCwd)
info, err := d.Info()
if err != nil {
return err
}
files[filepath.ToSlash(rel)] = sum
files[filepath.ToSlash(rel)] = driveStatusLocalFile{PathToCwd: relToCwd, ModTime: info.ModTime()}
return nil
})
if err != nil {
@@ -215,6 +268,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
return files, nil
}
func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Time) bool {
cmp, ok := compareDriveRemoteModifiedToLocal(remoteModified, local)
return ok && cmp == 0
}
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
f, err := runtime.FileIO().Open(path)
if err != nil {
@@ -244,7 +302,7 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi
return hex.EncodeToString(h.Sum(nil)), nil
}
func mergeStatusPaths(local, remote map[string]string) []string {
func mergeStatusPaths(local map[string]driveStatusLocalFile, remote map[string]driveStatusRemoteFile) []string {
seen := make(map[string]struct{}, len(local)+len(remote))
for p := range local {
seen[p] = struct{}{}

View File

@@ -4,16 +4,32 @@
package drive
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// driveStatusScopedTokenResolver returns a token with caller-controlled scopes
// so tests can deterministically exercise the shortcut scope preflight.
type driveStatusScopedTokenResolver struct {
scopes string
}
// ResolveToken satisfies credential.TokenProvider for scope-preflight tests.
func (r *driveStatusScopedTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token", Scopes: r.scopes}, nil
}
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
// against a real walk of the temp dir and a mocked Drive listing.
func TestDriveStatusCategorizesByHash(t *testing.T) {
@@ -105,6 +121,9 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, `"detection": "exact"`) {
t.Fatalf("output missing detection=exact\noutput: %s", out)
}
checks := []struct {
bucket string
path string
@@ -134,6 +153,264 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
reg.Verify(t)
}
func TestDriveStatusQuickCategorizesByModifiedTimeWithoutDownloads(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local/sub", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil {
t.Fatalf("WriteFile b.txt: %v", err)
}
if err := os.WriteFile("local/sub/c.txt", []byte("local-c"), 0o644); err != nil {
t.Fatalf("WriteFile sub/c.txt: %v", err)
}
matchTime := time.Unix(1715594880, 0)
changedTime := time.Unix(1715594940, 0)
if err := os.Chtimes("local/a.txt", matchTime, matchTime); err != nil {
t.Fatalf("Chtimes a.txt: %v", err)
}
if err := os.Chtimes("local/sub/c.txt", changedTime, changedTime); err != nil {
t.Fatalf("Chtimes sub/c.txt: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "1715594880"},
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file", "modified_time": "1715595000"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=tok_sub",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file", "modified_time": "1715594880"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--quick",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"detection": "quick"`) {
t.Fatalf("output missing detection=quick\noutput: %s", out)
}
checks := []struct {
bucket string
path string
token string
}{
{"new_local", "b.txt", ""},
{"new_remote", "d.txt", "tok_d"},
{"modified", "sub/c.txt", "tok_c"},
{"unchanged", "a.txt", "tok_a"},
}
for _, c := range checks {
if !strings.Contains(out, `"`+c.bucket+`":`) {
t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out)
}
if !strings.Contains(out, `"rel_path": "`+c.path+`"`) {
t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out)
}
if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) {
t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out)
}
}
reg.Verify(t)
}
// TestDriveStatusQuickMarksUntrustedTimestampAsModified locks in the
// conservative fallback for malformed remote modified_time values.
func TestDriveStatusQuickMarksUntrustedTimestampAsModified(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("local"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "not-a-timestamp"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--quick",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"detection": "quick"`) {
t.Fatalf("output missing detection=quick\noutput: %s", out)
}
if !strings.Contains(out, `"modified":`) || !strings.Contains(out, `"rel_path": "a.txt"`) {
t.Fatalf("invalid remote modified_time must fall back to modified\noutput: %s", out)
}
reg.Verify(t)
}
// TestDriveStatusExactRejectsMissingDownloadScope proves that exact mode keeps
// requiring drive:file:download even after quick mode made download optional.
func TestDriveStatusExactRejectsMissingDownloadScope(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("local"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected missing_scope error for exact mode without drive:file:download")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected structured exit error, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_scope" {
t.Fatalf("expected missing_scope detail, got %#v", exitErr.Detail)
}
if !strings.Contains(err.Error(), "missing required scope(s): drive:file:download") {
t.Fatalf("unexpected error: %v", err)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Hint, "auth login --scope") {
t.Fatalf("missing scope hint not found in detail: %#v", exitErr.Detail)
}
if !strings.Contains(err.Error(), "drive:file:download") {
t.Fatalf("error should mention drive:file:download: %v", err)
}
}
// TestDriveStatusQuickAcceptsMissingDownloadScope ensures quick mode is not
// blocked on the exact-mode download scope precheck.
func TestDriveStatusQuickAcceptsMissingDownloadScope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("local"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "not-a-timestamp"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--quick",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("quick mode should not require drive:file:download: %v\nstdout: %s", err, stdout.String())
}
if !strings.Contains(stdout.String(), `"detection": "quick"`) {
t.Fatalf("output missing detection=quick\noutput: %s", stdout.String())
}
reg.Verify(t)
}
// TestDriveStatusShouldTreatAsUnchangedQuick exercises the tiny quick helper
// directly so Codecov also sees coverage on the helper body itself.
func TestDriveStatusShouldTreatAsUnchangedQuick(t *testing.T) {
t.Run("matching timestamp returns true", func(t *testing.T) {
if !driveStatusShouldTreatAsUnchangedQuick("1715594880", time.Unix(1715594880, 500)) {
t.Fatal("expected matching second-resolution timestamps to be unchanged")
}
})
t.Run("different timestamp returns false", func(t *testing.T) {
if driveStatusShouldTreatAsUnchangedQuick("1715594881", time.Unix(1715594880, 0)) {
t.Fatal("expected different timestamps to be treated as modified")
}
})
t.Run("invalid timestamp returns false", func(t *testing.T) {
if driveStatusShouldTreatAsUnchangedQuick("not-a-timestamp", time.Unix(1715594880, 0)) {
t.Fatal("expected invalid timestamp to be treated as modified")
}
})
}
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`

View File

@@ -27,6 +27,7 @@ const (
type driveUploadSpec struct {
FilePath string
FileToken string
FolderToken string
WikiToken string
Name string
@@ -37,9 +38,15 @@ type driveUploadTarget struct {
ParentNode string
}
type driveUploadResult struct {
FileToken string
Version string
}
func newDriveUploadSpec(runtime *common.RuntimeContext) driveUploadSpec {
return driveUploadSpec{
FilePath: runtime.Str("file"),
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
Name: runtime.Str("name"),
@@ -89,6 +96,7 @@ var DriveUpload = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "file-token", Desc: "existing file token to overwrite in place"},
{Name: "folder-token", Desc: "target folder token (default: root folder; mutually exclusive with --wiki-token)"},
{Name: "wiki-token", Desc: "target wiki node token (uploads under that wiki node; mutually exclusive with --folder-token)"},
{Name: "name", Desc: "uploaded file name (default: local file name)"},
@@ -96,6 +104,8 @@ var DriveUpload = common.Shortcut{
Tips: []string{
"Omit both --folder-token and --wiki-token to upload into the caller's Drive root folder.",
"Use --wiki-token <wiki_node_token> to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
"Pass --file-token <file_token> to overwrite an existing Drive file in place; the shortcut forwards file_token to the upload API.",
"In bot mode, automatic full_access (可管理权限) grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveUploadSpec(runtime, newDriveUploadSpec(runtime))
@@ -103,22 +113,28 @@ var DriveUpload = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newDriveUploadSpec(runtime)
target := spec.Target()
isOverwrite := spec.FileToken != ""
body := map[string]interface{}{
"file_name": spec.FileName(),
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"file": "@" + spec.FilePath,
}
if spec.FileToken != "" {
body["file_token"] = spec.FileToken
}
d := common.NewDryRunAPI().
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)").
POST("/open-apis/drive/v1/files/upload_all").
Body(map[string]interface{}{
"file_name": spec.FileName(),
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"file": "@" + spec.FilePath,
})
if runtime.IsBot() {
Body(body)
if runtime.IsBot() && !isOverwrite {
d.Desc("After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newDriveUploadSpec(runtime)
isOverwrite := spec.FileToken != ""
fileName := spec.FileName()
target := spec.Target()
@@ -130,32 +146,37 @@ var DriveUpload = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> %s\n", fileName, common.FormatSize(fileSize), target.Label())
var fileToken string
var uploadResult driveUploadResult
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
fileToken, err = uploadFileMultipart(ctx, runtime, spec.FilePath, fileName, target, fileSize)
uploadResult, err = uploadFileMultipart(ctx, runtime, spec.FilePath, fileName, target, fileSize, spec.FileToken)
} else {
fileToken, err = uploadFileToDrive(ctx, runtime, spec.FilePath, fileName, target, fileSize)
uploadResult, err = uploadFileToDrive(ctx, runtime, spec.FilePath, fileName, target, fileSize, spec.FileToken)
}
if err != nil {
return err
}
out := map[string]interface{}{
"file_token": fileToken,
"file_token": uploadResult.FileToken,
"file_name": fileName,
"size": fileSize,
}
if uploadResult.Version != "" {
out["version"] = uploadResult.Version
}
// wiki-hosted files have no standalone /file/<token> URL — only the
// wiki node URL, which the upload response doesn't carry. Skip the
// fallback for parent_type=wiki rather than emit a link that 404s.
if target.ParentType == driveUploadParentTypeExplorer {
if u := common.BuildResourceURL(runtime.Config.Brand, "file", fileToken); u != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, "file", uploadResult.FileToken); u != "" {
out["url"] = u
}
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil {
out["permission_grant"] = grant
if !isOverwrite {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, uploadResult.FileToken, "file"); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
@@ -164,6 +185,9 @@ var DriveUpload = common.Shortcut{
}
func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpec) error {
if driveUploadFlagExplicitlyEmpty(runtime, "file-token") {
return common.FlagErrorf("--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite")
}
if driveUploadFlagExplicitlyEmpty(runtime, "folder-token") {
return common.FlagErrorf("--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token")
}
@@ -191,6 +215,11 @@ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpe
return output.ErrValidation("%s", err)
}
}
if spec.FileToken != "" {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
return nil
}
@@ -200,10 +229,10 @@ func driveUploadFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName str
strings.TrimSpace(runtime.Str(flagName)) == ""
}
func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64) (string, error) {
func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) {
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return driveUploadResult{}, common.WrapInputStatError(err)
}
defer f.Close()
@@ -213,6 +242,9 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
fd.AddField("parent_type", target.ParentType)
fd.AddField("parent_node", target.ParentNode)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if existingFileToken != "" {
fd.AddField("file_token", existingFileToken)
}
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
@@ -223,34 +255,37 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return "", err
return driveUploadResult{}, err
}
return "", output.ErrNetwork("upload failed: %v", err)
return driveUploadResult{}, output.ErrNetwork("upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
}
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
fileToken, _ := data["file_token"].(string)
fileToken := common.GetString(data, "file_token")
if fileToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
}
return fileToken, nil
return driveUploadResult{
FileToken: fileToken,
Version: driveUploadVersionFromData(data),
}, nil
}
// uploadFileMultipart uploads a large file using the three-step multipart API:
// 1. upload_prepare — get upload_id, block_size, block_num
// 2. upload_part — upload each block sequentially
// 3. upload_finish — finalize and get file_token
func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64) (string, error) {
// 3. upload_finish — finalize and get file_token/version
func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) {
// Step 1: Prepare
prepareBody := map[string]interface{}{
"file_name": fileName,
@@ -258,9 +293,12 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
"parent_node": target.ParentNode,
"size": fileSize,
}
if existingFileToken != "" {
prepareBody["file_token"] = existingFileToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
return driveUploadResult{}, err
}
uploadID := common.GetString(prepareResult, "upload_id")
@@ -270,7 +308,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
blockNum := int(blockNumF)
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return "", output.Errorf(output.ExitAPI, "api_error",
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error",
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
}
@@ -288,7 +326,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
partFile, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return driveUploadResult{}, common.WrapInputStatError(err)
}
fd := larkcore.NewFormdata()
@@ -306,18 +344,18 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return "", err
return driveUploadResult{}, err
}
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err)
return driveUploadResult{}, output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err)
}
var partResult map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
}
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
msg, _ := partResult["msg"].(string)
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
@@ -330,13 +368,24 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
if err != nil {
return "", err
return driveUploadResult{}, err
}
fileToken := common.GetString(finishResult, "file_token")
if fileToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
}
return fileToken, nil
return driveUploadResult{
FileToken: fileToken,
Version: driveUploadVersionFromData(finishResult),
}, nil
}
func driveUploadVersionFromData(data map[string]interface{}) string {
version := common.GetString(data, "version")
if version == "" {
version = common.GetString(data, "data_version")
}
return version
}

View File

@@ -11,6 +11,7 @@ import (
"path"
"sort"
"strconv"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -195,6 +196,9 @@ const (
driveDuplicateRemoteOldest = "oldest"
)
// sortRemoteFiles orders duplicate Drive files according to the conflict
// strategy, using parsed Drive timestamps so mixed second/millisecond/
// microsecond epochs compare by actual time rather than raw integer width.
func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
sort.SliceStable(files, func(i, j int) bool {
a, b := files[i], files[j]
@@ -226,16 +230,61 @@ func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
})
}
// compareDriveTimes compares two Drive epoch strings after normalizing their
// unit (seconds, milliseconds, or microseconds) into time.Time values.
func compareDriveTimes(a, b string) (int, bool) {
av, aErr := strconv.ParseInt(a, 10, 64)
bv, bErr := strconv.ParseInt(b, 10, 64)
if aErr != nil || bErr != nil {
at, _, aOK := parseDriveEpoch(a)
bt, _, bOK := parseDriveEpoch(b)
if !aOK || !bOK {
return 0, false
}
switch {
case av < bv:
case at.Before(bt):
return -1, true
case av > bv:
case at.After(bt):
return 1, true
default:
return 0, true
}
}
func parseDriveEpoch(raw string) (time.Time, time.Duration, bool) {
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return time.Time{}, 0, false
}
// Drive timestamps are epoch strings. The API currently returns
// milliseconds, but tests and older payloads may still use seconds.
// Infer the unit conservatively from magnitude and compare local mtimes
// at the same resolution so sub-second filesystem noise does not force
// a transfer in smart mode.
switch {
case v > 1e14 || v < -1e14:
return time.UnixMicro(v), time.Microsecond, true
case v > 1e11 || v < -1e11:
return time.UnixMilli(v), time.Millisecond, true
default:
return time.Unix(v, 0), time.Second, true
}
}
// compareDriveRemoteModifiedToLocal compares one Drive modified_time string to a
// local file mtime.
// - returns -1 when remote < local
// - returns 0 when remote == local at the remote timestamp resolution
// - returns 1 when remote > local
//
// The bool reports whether the remote timestamp was parseable.
func compareDriveRemoteModifiedToLocal(remoteModified string, local time.Time) (int, bool) {
remoteTime, resolution, ok := parseDriveEpoch(remoteModified)
if !ok {
return 0, false
}
localAtRemoteResolution := local.Truncate(resolution)
switch {
case remoteTime.Before(localAtRemoteResolution):
return -1, true
case remoteTime.After(localAtRemoteResolution):
return 1, true
default:
return 0, true

View File

@@ -15,6 +15,7 @@ import (
"github.com/spf13/cobra"
)
// mustMarshalDryRun marshals v to a JSON string, calling t.Fatalf on error.
func mustMarshalDryRun(t *testing.T, v interface{}) string {
t.Helper()
@@ -25,6 +26,9 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
return string(b)
}
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
// command whose flags are populated from the provided string and bool maps,
// for unit-testing shortcut bodies, validators, and dry-run shapes.
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -55,6 +59,9 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
return &common.RuntimeContext{Cmd: cmd}
}
// newMessagesSearchTestRuntimeContext is the messages-search variant of
// newTestRuntimeContext: registers the search-specific --page-size flag
// before applying caller-provided values.
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -86,6 +93,8 @@ func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]st
return &common.RuntimeContext{Cmd: cmd}
}
// TestBuildCreateChatBody verifies the request body assembled when every
// flag is populated, including the default chat_mode="group".
func TestBuildCreateChatBody(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
@@ -94,11 +103,13 @@ func TestBuildCreateChatBody(t *testing.T) {
"users": "ou_1, ou_2",
"bots": "cli_1, cli_2",
"owner": "ou_owner",
"chat-mode": "group",
}, nil)
got := buildCreateChatBody(runtime)
want := map[string]interface{}{
"chat_type": "public",
"chat_mode": "group",
"name": "Team Chat",
"description": "daily sync",
"user_id_list": []string{
@@ -116,6 +127,43 @@ func TestBuildCreateChatBody(t *testing.T) {
}
}
// TestBuildCreateChatBody_TopicMode verifies that --chat-mode topic produces
// chat_mode="topic" in the request body, the topic-chat creation path.
func TestBuildCreateChatBody_TopicMode(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
"name": "Topic Group",
"chat-mode": "topic",
}, nil)
got := buildCreateChatBody(runtime)
want := map[string]interface{}{
"chat_type": "public",
"chat_mode": "topic",
"name": "Topic Group",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildCreateChatBody() = %#v, want %#v", got, want)
}
}
// TestBuildCreateChatBody_EmptyChatModeFallsBack pins the defensive fallback:
// explicit `--chat-mode ""` slips past validateEnumFlags (which skips empty
// values), but buildCreateChatBody must still emit chat_mode="group" rather
// than an empty string with unspecified server semantics.
func TestBuildCreateChatBody_EmptyChatModeFallsBack(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
"name": "Fallback Test",
"chat-mode": "",
}, nil)
got := buildCreateChatBody(runtime)
if got["chat_mode"] != "group" {
t.Fatalf("buildCreateChatBody() chat_mode = %#v, want \"group\"", got["chat_mode"])
}
}
// TestSplitMembers verifies the delegation wrapper; core logic is tested in TestSplitCSV. [#17]
func TestSplitMembers(t *testing.T) {
got := common.SplitCSV(" ou_1, ,ou_2 ,, ou_3 ")
@@ -591,10 +639,12 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
})
}
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
// produces the expected API path, query parameters, and request body.
func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"type", "name", "users", "owner"} {
for _, name := range []string{"type", "name", "users", "owner", "chat-mode"} {
cmd.Flags().String(name, "", "")
}
cmd.Flags().Bool("set-bot-manager", false, "")
@@ -604,9 +654,10 @@ func TestShortcutDryRunShapes(t *testing.T) {
_ = cmd.Flags().Set("users", "ou_1,ou_2")
_ = cmd.Flags().Set("owner", "ou_owner")
_ = cmd.Flags().Set("set-bot-manager", "true")
_ = cmd.Flags().Set("chat-mode", "group")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, "bot")
got := mustMarshalDryRun(t, ImChatCreate.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) {
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) || !strings.Contains(got, `"chat_mode":"group"`) {
t.Fatalf("ImChatCreate.DryRun() = %s", got)
}
})
@@ -623,6 +674,25 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
}, map[string]bool{
"exclude-muted": true,
})
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
// Filter is client-side; --exclude-muted must NOT mutate request body or auto-inject search_types.
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) {
t.Fatalf("ImChatSearch.DryRun() missing endpoint: %s", got)
}
if strings.Contains(got, `"exclude_muted"`) || strings.Contains(got, `"exclude-muted"`) {
t.Fatalf("--exclude-muted leaked into request: %s", got)
}
if strings.Contains(got, `"search_types"`) {
t.Fatalf("search_types must not be auto-injected by --exclude-muted: %s", got)
}
})
t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "incident",
@@ -758,6 +828,20 @@ func TestShortcutDryRunShapes(t *testing.T) {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
})
t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
t.Fatalf("ImChatList.DryRun() = %s", got)
}
if !strings.Contains(got, `"sort_type":"ByCreateTimeAsc"`) {
t.Fatalf("ImChatList.DryRun() missing sort_type: %s", got)
}
})
}
func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
@@ -772,3 +856,26 @@ func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
}
func TestDetectAllNonMemberPreSkip(t *testing.T) {
cases := []struct {
name string
searchTypes string
want string
}{
{"empty", "", ""},
{"only public_not_joined", "public_not_joined", SkipReasonAllNonMember},
{"public_not_joined with whitespace", " public_not_joined ", SkipReasonAllNonMember},
{"private only", "private", ""},
{"mixed includes public_not_joined", "public_not_joined,private", ""},
{"all four types", "private,public_joined,external,public_not_joined", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := detectAllNonMemberPreSkip(c.searchTypes)
if got != c.want {
t.Fatalf("detectAllNonMemberPreSkip(%q) = %q, want %q", c.searchTypes, got, c.want)
}
})
}
}

View File

@@ -859,6 +859,7 @@ func TestShortcuts(t *testing.T) {
want := []string{
"+chat-create",
"+chat-list",
"+chat-messages-list",
"+chat-search",
"+chat-update",

View File

@@ -16,10 +16,14 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ImChatCreate is the +chat-create shortcut: creates a group chat or topic
// chat via POST /open-apis/im/v1/chats. Supports user and bot identities;
// --chat-mode selects group (default) or topic; --type selects private
// (default) or public; --users/--bots invite members at creation.
var ImChatCreate = common.Shortcut{
Service: "im",
Command: "+chat-create",
Description: "Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager",
Description: "Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager",
Risk: "write",
UserScopes: []string{"im:chat:create_by_user"},
BotScopes: []string{"im:chat:create"},
@@ -32,6 +36,7 @@ var ImChatCreate = common.Shortcut{
{Name: "bots", Desc: "comma-separated bot app IDs (cli_xxx) to invite, max 5"},
{Name: "owner", Desc: "owner open_id (ou_xxx); defaults to bot (--as bot) or authorized user (--as user)"},
{Name: "type", Default: "private", Desc: "chat type", Enum: []string{"private", "public"}},
{Name: "chat-mode", Default: "group", Desc: "group mode (\"topic\" creates a topic chat; differs from a normal group in topic-message mode)", Enum: []string{"group", "topic"}},
{Name: "set-bot-manager", Type: "bool", Desc: "set the bot that creates this chat as manager (bot identity only)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -141,9 +146,18 @@ var ImChatCreate = common.Shortcut{
},
}
// buildCreateChatBody assembles the POST /open-apis/im/v1/chats request
// body. chat_mode is always emitted; an empty value (which can slip past
// validateEnumFlags, since that helper skips empty strings) is pinned to
// "group" so the wire never carries an unspecified chat_mode value.
func buildCreateChatBody(runtime *common.RuntimeContext) map[string]interface{} {
chatMode := runtime.Str("chat-mode")
if chatMode == "" {
chatMode = "group"
}
body := map[string]interface{}{
"chat_type": runtime.Str("type"),
"chat_mode": chatMode,
}
if name := runtime.Str("name"); name != "" {
body["name"] = name

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
const imChatListPath = "/open-apis/im/v1/chats"
// ImChatList is the +chat-list shortcut: wraps GET /open-apis/im/v1/chats to
// list groups the current user/bot is a member of. Supports sort order,
// pagination, and (user identity only) muted-chat filtering via --exclude-muted.
var ImChatList = common.Shortcut{
Service: "im",
Command: "+chat-list",
Description: "List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(imChatListPath).
Params(buildChatListParams(runtime))
},
// Validate enforces flag preconditions; only --page-size has bounds (1-100).
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if n := runtime.Int("page-size"); n < 1 || n > 100 {
return output.ErrValidation("--page-size must be an integer between 1 and 100")
}
return nil
},
// Execute fetches one page of chats, optionally applies --exclude-muted
// via MaybeApplyMuteFilter, and renders the result. outData["filter"] is
// populated only when --exclude-muted is set (backward compatible).
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := buildChatListParams(runtime)
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
if err != nil {
return err
}
rawItems, _ := resData["items"].([]interface{})
hasMore, pageToken := common.PaginationMeta(resData)
var items []map[string]interface{}
for _, raw := range rawItems {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
items = append(items, item)
}
mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
ExcludeMuted: runtime.Bool("exclude-muted"),
IsBot: runtime.IsBot(),
Chats: items,
ChatIDKey: "chat_id",
HasMore: hasMore,
})
if err != nil {
return err
}
items = mfOut.Chats
outData := map[string]interface{}{
"chats": items,
"has_more": hasMore,
"page_token": pageToken,
}
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(items) == 0 {
fmt.Fprintln(w, "No chats found.")
if mfOut.Meta.Hint != "" {
fmt.Fprintln(w, mfOut.Meta.Hint)
}
return
}
rows := make([]map[string]interface{}, 0, len(items))
for _, m := range items {
row := map[string]interface{}{
"chat_id": m["chat_id"],
"name": m["name"],
}
if desc, _ := m["description"].(string); desc != "" {
row["description"] = desc
}
if ownerID, _ := m["owner_id"].(string); ownerID != "" {
row["owner_id"] = ownerID
}
if external, ok := m["external"].(bool); ok {
row["external"] = external
}
if status, _ := m["chat_status"].(string); status != "" {
row["chat_status"] = status
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d chat(s) listed", len(rows))
if hasMore {
fmt.Fprint(w, " (more available, use --page-token to fetch next page")
if pageToken != "" {
fmt.Fprintf(w, ", page_token: %s", pageToken)
}
fmt.Fprint(w, ")")
}
fmt.Fprintln(w)
if mfOut.Meta.Hint != "" {
fmt.Fprintln(w, mfOut.Meta.Hint)
}
})
return nil
},
}
// buildChatListParams builds the query parameters for the GET /im/v1/chats
// call from the runtime flag values. user_id_type and sort_type are always
// present (their flag defaults are non-empty); page_token is omitted when
// empty; page_size falls back to the API default of 20 when not provided.
func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"sort_type": runtime.Str("sort-type"),
}
if n := runtime.Int("page-size"); n > 0 {
params["page_size"] = n
} else {
params["page_size"] = 20
}
if pt := runtime.Str("page-token"); pt != "" {
params["page_token"] = pt
}
return params
}

View File

@@ -0,0 +1,128 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newChatListTestRuntimeContext mirrors newMessagesSearchTestRuntimeContext —
// it registers page-size as Int (the existing newTestRuntimeContext registers
// it as String, which would short-circuit our buildChatListParams logic).
func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().String(name, "", "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestBuildChatListParams_Defaults(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt)
if got["user_id_type"] != "open_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
if got["sort_type"] != "ByCreateTimeAsc" {
t.Fatalf("sort_type = %v", got["sort_type"])
}
if got["page_size"] != 20 {
t.Fatalf("page_size = %v, want 20", got["page_size"])
}
if _, present := got["page_token"]; present {
t.Fatalf("page_token should be omitted when empty")
}
}
func TestBuildChatListParams_Overrides(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "user_id",
"sort-type": "ByActiveTimeDesc",
"page-size": "50",
"page-token": "tok_xyz",
}, nil)
got := buildChatListParams(rt)
if got["user_id_type"] != "user_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
if got["sort_type"] != "ByActiveTimeDesc" {
t.Fatalf("sort_type = %v", got["sort_type"])
}
if got["page_size"] != 50 {
t.Fatalf("page_size = %v, want 50", got["page_size"])
}
if got["page_token"] != "tok_xyz" {
t.Fatalf("page_token = %v", got["page_token"])
}
}
func TestImChatList_Validate_PageSizeBounds(t *testing.T) {
cases := []struct {
name string
pageSize string
wantErr bool
}{
{"zero rejected", "0", true},
{"negative rejected", "-1", true},
{"one ok", "1", false},
{"hundred ok", "100", false},
{"oneoone rejected", "101", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{"page-size": c.pageSize}, nil)
err := ImChatList.Validate(context.Background(), rt)
if (err != nil) != c.wantErr {
t.Fatalf("Validate() err = %v, wantErr=%v", err, c.wantErr)
}
})
}
}
func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByActiveTimeDesc",
"page-size": "30",
}, nil)
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
t.Fatalf("DryRun missing endpoint: %s", got)
}
if !strings.Contains(got, `"sort_type":"ByActiveTimeDesc"`) {
t.Fatalf("DryRun missing sort_type: %s", got)
}
if !strings.Contains(got, `"page_size":30`) {
t.Fatalf("DryRun missing page_size: %s", got)
}
}

View File

@@ -15,10 +15,14 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// ImChatSearch is the +chat-search shortcut: wraps POST /open-apis/im/v2/chats/search
// to find visible group chats by keyword and/or member open_ids. Supports
// member/type filters, sort order, pagination, and (user identity only) the
// --exclude-muted client-side mute filter.
var ImChatSearch = common.Shortcut{
Service: "im",
Command: "+chat-search",
Description: "Search visible group chats by keyword and/or member open_ids (e.g. look up chat_id by group name); user/bot; supports member/type filters, sorting, and pagination",
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
@@ -32,7 +36,9 @@ var ImChatSearch = common.Shortcut{
{Name: "sort-by", Desc: "sort field (descending)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
// DryRun previews the POST /open-apis/im/v2/chats/search request without executing.
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildSearchChatBody(runtime)
params := buildSearchChatParams(runtime)
@@ -41,6 +47,8 @@ var ImChatSearch = common.Shortcut{
Params(params).
Body(body)
},
// Validate enforces query/member-ids presence, --query rune cap, search-types
// enum, --member-ids count and format, and --page-size bounds.
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
query := runtime.Str("query")
memberIDs := runtime.Str("member-ids")
@@ -79,6 +87,10 @@ var ImChatSearch = common.Shortcut{
}
return nil
},
// Execute fetches one page, extracts per-item meta_data, optionally applies
// the --exclude-muted client-side filter (with a PreSkipReason when
// --search-types is exactly public_not_joined), and renders the result.
// outData["filter"] is populated only when --exclude-muted is set.
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildSearchChatBody(runtime)
params := buildSearchChatParams(runtime)
@@ -106,16 +118,39 @@ var ImChatSearch = common.Shortcut{
items = append(items, meta)
}
preSkipReason := ""
if runtime.Bool("exclude-muted") {
preSkipReason = detectAllNonMemberPreSkip(runtime.Str("search-types"))
}
mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
ExcludeMuted: runtime.Bool("exclude-muted"),
IsBot: runtime.IsBot(),
PreSkipReason: preSkipReason,
Chats: items,
ChatIDKey: "chat_id",
HasMore: hasMore,
})
if err != nil {
return err
}
items = mfOut.Chats
outData := map[string]interface{}{
"chats": items,
"total": int(total),
"has_more": hasMore,
"page_token": pageToken,
}
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(items) == 0 {
fmt.Fprintln(w, "No matching group chats found.")
if mfOut.Meta.Hint != "" {
fmt.Fprintln(w, mfOut.Meta.Hint)
}
return
}
var rows []map[string]interface{}
@@ -154,11 +189,19 @@ var ImChatSearch = common.Shortcut{
moreHint += ")"
}
fmt.Fprintf(w, "\n%d chat(s) found%s\n", int(total), moreHint)
if mfOut.Meta.Hint != "" {
fmt.Fprintln(w, mfOut.Meta.Hint)
}
})
return nil
},
}
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
// from the runtime flag values. The query string is normalized via
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
// is omitted when no filter flags are set; "sorter" is omitted when --sort-by
// is empty.
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
@@ -194,6 +237,9 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
return body
}
// buildSearchChatParams builds the query parameters for the POST
// /im/v2/chats/search call. page_size defaults to the API default of 20 when
// not provided; page_token is omitted when empty.
func buildSearchChatParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if n := runtime.Int("page-size"); n > 0 {
@@ -207,10 +253,11 @@ func buildSearchChatParams(runtime *common.RuntimeContext) map[string]interface{
return params
}
// normalizeChatSearchQuery wraps hyphenated search queries in double quotes
// because the search API treats hyphenated keywords specially and expects the
// whole query to be quoted. Already-quoted input is unwrapped before requoting
// so we don't emit nested quotes. Inputs without "-" pass through unchanged.
func normalizeChatSearchQuery(query string) string {
// The search API treats hyphenated keywords specially and expects the whole
// query to be quoted. Normalize already-quoted input before requoting so we
// don't emit nested quotes.
if !strings.Contains(query, "-") {
return query
}
@@ -219,3 +266,15 @@ func normalizeChatSearchQuery(query string) string {
}
return strconv.Quote(query)
}
// detectAllNonMemberPreSkip returns SkipReasonAllNonMember when --search-types
// is exactly "public_not_joined" — the one combination guaranteeing no member
// chats, making the mute filter a no-op. Any other value (including empty or
// mixed) returns "".
func detectAllNonMemberPreSkip(searchTypesCSV string) string {
types := common.SplitCSV(searchTypesCSV)
if len(types) == 1 && types[0] == "public_not_joined" {
return SkipReasonAllNonMember
}
return ""
}

320
shortcuts/im/mute_filter.go Normal file
View File

@@ -0,0 +1,320 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package-level helper: client-side filter that drops muted chats from
// search/list results by calling /open-apis/im/v1/chat_user_setting/batch_get_mute_status.
//
// The native chat search/list APIs do not return mute status; we fetch it as
// a separate batch lookup, then drop is_muted=true items. Non-member /
// invalid-format chat_ids come back via invalid_id_list and are silently
// retained (we don't know their mute state). Bot identity is unsupported by
// the upstream API (UAT-only), so we skip the filter and emit a machine-readable
// skipped indicator instead of erroring.
package im
import (
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// MuteFilterMeta describes the outcome of a single page's mute filter run.
// UnknownCount is internal — used to compose the hint, not exposed in JSON.
type MuteFilterMeta struct {
Applied string
Skipped bool
SkipReason string
FetchedCount int
ReturnedCount int
FilteredCount int
UnknownCount int
Hint string
}
// MaxMuteStatusBatchSize is the upstream cap for chat_ids per
// batch_get_mute_status call (after dedupe).
const MaxMuteStatusBatchSize = 100
// BatchGetMuteStatusPath is the upstream HTTP path.
const BatchGetMuteStatusPath = "/open-apis/im/v1/chat_user_setting/batch_get_mute_status"
// SkipReason constants — written to filter.skip_reason when Skipped=true.
const (
SkipReasonBotIdentity = "bot_identity_no_mute_data"
SkipReasonAllNonMember = "all_non_member_search_types"
)
// BuildMuteFilterHint composes the user/AI-facing English hint for a finished
// filter run. hasMore is the underlying API's has_more (so we can suggest paging).
// Returns "" when the filter ran but had no effect (FilteredCount==0 and not skipped).
func BuildMuteFilterHint(meta MuteFilterMeta, hasMore bool) string {
if meta.Skipped {
switch meta.SkipReason {
case SkipReasonBotIdentity:
return "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter."
case SkipReasonAllNonMember:
if hasMore {
return "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more."
}
return "All results on this page are non-member public groups; mute filter does not apply. No more pages."
}
return ""
}
if meta.FilteredCount == 0 {
return ""
}
tail := "no more pages."
if hasMore {
tail = "use --page-token to fetch more."
}
if meta.UnknownCount > 0 {
return fmt.Sprintf("Filtered out %d muted chat(s) on this page (%d remaining, including %d non-member public group(s)); %s",
meta.FilteredCount, meta.ReturnedCount, meta.UnknownCount, tail)
}
return fmt.Sprintf("Filtered out %d muted chat(s) on this page (%d remaining); %s",
meta.FilteredCount, meta.ReturnedCount, tail)
}
// BuildBatchGetMuteStatusBody constructs the request body for
// POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status.
func BuildBatchGetMuteStatusBody(chatIDs []string) map[string]interface{} {
return map[string]interface{}{"chat_ids": chatIDs}
}
// ParseBatchGetMuteStatusResponse maps the API response to:
// - muted: chat_id -> is_muted, only for ids returned in items
// - unknown: chat_ids that came back in invalid_id_list (any msg) OR
// were in input but missing from both lists.
//
// unknown preserves input order for stable hint output.
func ParseBatchGetMuteStatusResponse(input []string, resp map[string]interface{}) (map[string]bool, []string) {
muted := make(map[string]bool, len(input))
if rawItems, ok := resp["items"].([]interface{}); ok {
for _, raw := range rawItems {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
cid, _ := item["chat_id"].(string)
if cid == "" {
continue
}
isMuted, _ := item["is_muted"].(bool)
muted[cid] = isMuted
}
}
unknownSet := make(map[string]struct{})
if rawInvalid, ok := resp["invalid_id_list"].([]interface{}); ok {
for _, raw := range rawInvalid {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
id, _ := item["id"].(string)
if id != "" {
unknownSet[id] = struct{}{}
}
}
}
for _, id := range input {
if _, hasMute := muted[id]; hasMute {
continue
}
unknownSet[id] = struct{}{}
}
unknown := make([]string, 0, len(unknownSet))
for _, id := range input {
if _, ok := unknownSet[id]; ok {
unknown = append(unknown, id)
delete(unknownSet, id) // dedupe while preserving input order
}
}
return muted, unknown
}
// ApplyMuteFilter drops chats whose mute map entry is true. Chats whose id
// is in the unknown set, or which have no chatIDKey value, are retained
// (we have no basis to filter them) and counted as UnknownCount.
//
// Pure function; no API calls. The caller is responsible for fetching the
// mute map via FetchMuteStatus.
//
// Invariant: meta.FetchedCount == meta.ReturnedCount + meta.FilteredCount.
func ApplyMuteFilter(
chats []map[string]interface{},
chatIDKey string,
muted map[string]bool,
unknown []string,
) ([]map[string]interface{}, MuteFilterMeta) {
unknownSet := make(map[string]struct{}, len(unknown))
for _, id := range unknown {
unknownSet[id] = struct{}{}
}
out := make([]map[string]interface{}, 0, len(chats))
meta := MuteFilterMeta{Applied: "exclude_muted", FetchedCount: len(chats)}
for _, row := range chats {
cid, _ := row[chatIDKey].(string)
if cid == "" {
out = append(out, row)
meta.UnknownCount++
continue
}
if _, isUnknown := unknownSet[cid]; isUnknown {
out = append(out, row)
meta.UnknownCount++
continue
}
if isMuted, ok := muted[cid]; ok {
if isMuted {
meta.FilteredCount++
continue
}
out = append(out, row)
continue
}
// Defensive: id not in muted, not in unknown — treat as unknown, retain.
out = append(out, row)
meta.UnknownCount++
}
meta.ReturnedCount = len(out)
return out, meta
}
// ExtractChatIDs collects unique chat_ids (in input order) from a page of rows.
// Rows missing the key or with an empty value are skipped.
func ExtractChatIDs(chats []map[string]interface{}, chatIDKey string) []string {
if len(chats) == 0 {
return nil
}
seen := make(map[string]struct{}, len(chats))
out := make([]string, 0, len(chats))
for _, row := range chats {
cid, _ := row[chatIDKey].(string)
if cid == "" {
continue
}
if _, dup := seen[cid]; dup {
continue
}
seen[cid] = struct{}{}
out = append(out, cid)
}
return out
}
// MuteFilterMetaToMap renders the meta as the "filter" sub-object the
// command writes into outData. The schema is fixed-shape: exactly 5 fields,
// regardless of skip state.
//
// Skip context (bot identity / all-non-member search-types) is encoded
// entirely in the Hint string — consumers read the natural-language hint
// to understand why the filter did or did not apply. UnknownCount and the
// Skipped / SkipReason struct fields are internal-only (used to compose
// Hint) and are not exposed in JSON.
func MuteFilterMetaToMap(meta MuteFilterMeta) map[string]interface{} {
return map[string]interface{}{
"applied": meta.Applied,
"fetched_count": meta.FetchedCount,
"returned_count": meta.ReturnedCount,
"filtered_count": meta.FilteredCount,
"hint": meta.Hint,
}
}
// FetchMuteStatus calls batch_get_mute_status for the given chat_ids and
// parses the result. Caller MUST ensure len(chatIDs) <= MaxMuteStatusBatchSize
// (the shortcuts already cap --page-size at 100, so a single page is safe).
//
// Empty input is a no-op (avoids triggering the upstream "chat_ids is empty"
// InvalidParam).
func FetchMuteStatus(runtime *common.RuntimeContext, chatIDs []string) (map[string]bool, []string, error) {
if len(chatIDs) == 0 {
return map[string]bool{}, nil, nil
}
if len(chatIDs) > MaxMuteStatusBatchSize {
return nil, nil, output.ErrValidation(
"batch_get_mute_status accepts at most %d chat_ids per call (got %d)",
MaxMuteStatusBatchSize, len(chatIDs))
}
body := BuildBatchGetMuteStatusBody(chatIDs)
resp, err := runtime.CallAPI("POST", BatchGetMuteStatusPath, nil, body)
if err != nil {
return nil, nil, fmt.Errorf("fetch mute status: %w", err)
}
muted, unknown := ParseBatchGetMuteStatusResponse(chatIDs, resp)
return muted, unknown, nil
}
// MuteFilterInput captures everything the orchestrator needs from the calling shortcut.
type MuteFilterInput struct {
ExcludeMuted bool // value of --exclude-muted
IsBot bool // current identity
PreSkipReason string // optional caller-supplied skip reason (e.g. SkipReasonAllNonMember); leave empty under bot — IsBot is handled separately
Chats []map[string]interface{} // page of result rows
ChatIDKey string // key in row holding the chat_id ("chat_id" for both v1 list and v2 search meta_data)
HasMore bool // for hint composition
}
// MuteFilterOutput is what the shortcut writes back into outData.
type MuteFilterOutput struct {
Chats []map[string]interface{} // filtered (or unchanged when not applied)
Meta MuteFilterMeta // zero-valued when ExcludeMuted=false; callers detect via Meta.Applied != ""
}
// MaybeApplyMuteFilter is the single entry point shortcuts call.
//
// Behavior:
// - ExcludeMuted=false: returns chats unchanged, Meta is zero-valued (Applied=="")
// - ExcludeMuted=true && IsBot: skip the API call, mark Skipped with SkipReasonBotIdentity
// - ExcludeMuted=true && PreSkipReason!="" (not bot): skip the API call, mark Skipped with that reason
// - ExcludeMuted=true && len(chats)==0: skip the API call (avoids upstream
// InvalidParam on empty chat_ids); meta has zero counts, Skipped=false
// - ExcludeMuted=true && otherwise: fetch + apply; populate counts and Hint
//
// Callers detect whether the filter ran via out.Meta.Applied != "".
// Callers compose the JSON map via MuteFilterMetaToMap(out.Meta) at the use site.
func MaybeApplyMuteFilter(runtime *common.RuntimeContext, in MuteFilterInput) (MuteFilterOutput, error) {
if !in.ExcludeMuted {
return MuteFilterOutput{Chats: in.Chats}, nil
}
meta := MuteFilterMeta{
Applied: "exclude_muted",
FetchedCount: len(in.Chats),
ReturnedCount: len(in.Chats),
}
switch {
case in.IsBot:
meta.Skipped = true
meta.SkipReason = SkipReasonBotIdentity
case in.PreSkipReason != "":
meta.Skipped = true
meta.SkipReason = in.PreSkipReason
case len(in.Chats) == 0:
// counts already zero; Skipped stays false
default:
ids := ExtractChatIDs(in.Chats, in.ChatIDKey)
muted, unknown, err := FetchMuteStatus(runtime, ids)
if err != nil {
return MuteFilterOutput{}, err
}
var filtered []map[string]interface{}
filtered, meta = ApplyMuteFilter(in.Chats, in.ChatIDKey, muted, unknown)
in.Chats = filtered
}
meta.Hint = BuildMuteFilterHint(meta, in.HasMore)
return MuteFilterOutput{
Chats: in.Chats,
Meta: meta,
}, nil
}

View File

@@ -0,0 +1,445 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"fmt"
"reflect"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBuildMuteFilterHint(t *testing.T) {
cases := []struct {
name string
meta MuteFilterMeta
hasMore bool
want string
}{
{
name: "1 skipped bot identity",
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonBotIdentity},
hasMore: false,
want: "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter.",
},
{
name: "2 skipped all non-member, has_more",
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonAllNonMember},
hasMore: true,
want: "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more.",
},
{
name: "3 skipped all non-member, no more",
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonAllNonMember},
hasMore: false,
want: "All results on this page are non-member public groups; mute filter does not apply. No more pages.",
},
{
name: "4 filtered>0 unknown=0 has_more",
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 17, FilteredCount: 3},
hasMore: true,
want: "Filtered out 3 muted chat(s) on this page (17 remaining); use --page-token to fetch more.",
},
{
name: "5 filtered>0 unknown=0 no more",
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 17, FilteredCount: 3},
hasMore: false,
want: "Filtered out 3 muted chat(s) on this page (17 remaining); no more pages.",
},
{
name: "6 filtered>0 unknown>0 has_more",
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2},
hasMore: true,
want: "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more.",
},
{
name: "7 filtered>0 unknown>0 no more",
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2},
hasMore: false,
want: "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); no more pages.",
},
{
name: "8 filtered=0 returns empty regardless of unknown/hasMore",
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 5, ReturnedCount: 5, UnknownCount: 2},
hasMore: true,
want: "",
},
{
name: "9 skipped with unrecognized reason returns empty",
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: "unknown_reason"},
hasMore: false,
want: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := BuildMuteFilterHint(c.meta, c.hasMore)
if got != c.want {
t.Fatalf("BuildMuteFilterHint() = %q, want %q", got, c.want)
}
})
}
}
func TestBuildBatchGetMuteStatusBody(t *testing.T) {
got := BuildBatchGetMuteStatusBody([]string{"oc_a", "oc_b"})
want := map[string]interface{}{"chat_ids": []string{"oc_a", "oc_b"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("BuildBatchGetMuteStatusBody() = %v, want %v", got, want)
}
}
func TestParseBatchGetMuteStatusResponse(t *testing.T) {
t.Run("happy path with mixed muted/non-muted/invalid", func(t *testing.T) {
input := []string{"oc_a", "oc_b", "oc_c", "bad"}
resp := map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"chat_id": "oc_a", "is_muted": true},
map[string]interface{}{"chat_id": "oc_b", "is_muted": false},
},
"invalid_id_list": []interface{}{
map[string]interface{}{"id": "oc_c", "msg": "not_a_member"},
map[string]interface{}{"id": "bad", "msg": "invalid_format"},
},
}
muted, unknown := ParseBatchGetMuteStatusResponse(input, resp)
wantMuted := map[string]bool{"oc_a": true, "oc_b": false}
wantUnknown := []string{"oc_c", "bad"}
if !reflect.DeepEqual(muted, wantMuted) {
t.Fatalf("muted = %v, want %v", muted, wantMuted)
}
if !reflect.DeepEqual(unknown, wantUnknown) {
t.Fatalf("unknown = %v, want %v", unknown, wantUnknown)
}
})
t.Run("missing chat_ids fall through to unknown", func(t *testing.T) {
input := []string{"oc_a", "oc_b"}
resp := map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"chat_id": "oc_a", "is_muted": true},
},
}
muted, unknown := ParseBatchGetMuteStatusResponse(input, resp)
if !reflect.DeepEqual(muted, map[string]bool{"oc_a": true}) {
t.Fatalf("muted = %v", muted)
}
if !reflect.DeepEqual(unknown, []string{"oc_b"}) {
t.Fatalf("unknown = %v", unknown)
}
})
t.Run("empty response yields all unknown", func(t *testing.T) {
input := []string{"oc_a"}
muted, unknown := ParseBatchGetMuteStatusResponse(input, map[string]interface{}{})
if len(muted) != 0 {
t.Fatalf("muted = %v, want empty", muted)
}
if !reflect.DeepEqual(unknown, []string{"oc_a"}) {
t.Fatalf("unknown = %v", unknown)
}
})
t.Run("skips nil entries and empty chat_id in items/invalid_id_list", func(t *testing.T) {
input := []string{"oc_a", "oc_b"}
resp := map[string]interface{}{
"items": []interface{}{
nil,
map[string]interface{}{"chat_id": "", "is_muted": false},
map[string]interface{}{"chat_id": "oc_a", "is_muted": true},
},
"invalid_id_list": []interface{}{
nil,
map[string]interface{}{"id": "oc_b", "msg": "not_a_member"},
},
}
muted, unknown := ParseBatchGetMuteStatusResponse(input, resp)
if !reflect.DeepEqual(muted, map[string]bool{"oc_a": true}) {
t.Fatalf("muted = %v", muted)
}
if !reflect.DeepEqual(unknown, []string{"oc_b"}) {
t.Fatalf("unknown = %v", unknown)
}
})
}
func TestApplyMuteFilter(t *testing.T) {
chats := []map[string]interface{}{
{"chat_id": "oc_a", "name": "alpha"},
{"chat_id": "oc_b", "name": "beta"},
{"chat_id": "oc_c", "name": "gamma"},
{"chat_id": "oc_d", "name": "delta"},
}
t.Run("drops only is_muted=true", func(t *testing.T) {
muted := map[string]bool{"oc_a": true, "oc_b": false, "oc_c": true, "oc_d": false}
got, meta := ApplyMuteFilter(chats, "chat_id", muted, nil)
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2", len(got))
}
if got[0]["chat_id"] != "oc_b" || got[1]["chat_id"] != "oc_d" {
t.Fatalf("got = %v, want [oc_b, oc_d]", got)
}
want := MuteFilterMeta{
Applied: "exclude_muted", FetchedCount: 4, ReturnedCount: 2, FilteredCount: 2, UnknownCount: 0,
}
if meta != want {
t.Fatalf("meta = %+v, want %+v", meta, want)
}
})
t.Run("retains unknown chats and counts them", func(t *testing.T) {
muted := map[string]bool{"oc_a": true, "oc_b": false}
unknown := []string{"oc_c", "oc_d"}
got, meta := ApplyMuteFilter(chats, "chat_id", muted, unknown)
if len(got) != 3 {
t.Fatalf("len(got) = %d, want 3 (oc_b + oc_c + oc_d)", len(got))
}
if meta.FilteredCount != 1 || meta.UnknownCount != 2 || meta.ReturnedCount != 3 {
t.Fatalf("meta = %+v, want filtered=1 unknown=2 returned=3", meta)
}
})
t.Run("preserves original order", func(t *testing.T) {
muted := map[string]bool{"oc_b": true}
got, _ := ApplyMuteFilter(chats, "chat_id", muted, []string{"oc_c", "oc_d"})
gotIDs := []string{}
for _, r := range got {
gotIDs = append(gotIDs, r["chat_id"].(string))
}
want := []string{"oc_a", "oc_c", "oc_d"}
if !reflect.DeepEqual(gotIDs, want) {
t.Fatalf("ordering = %v, want %v", gotIDs, want)
}
})
t.Run("missing chatIDKey treated as unknown but kept", func(t *testing.T) {
bad := []map[string]interface{}{{"name": "no_id"}}
got, meta := ApplyMuteFilter(bad, "chat_id", map[string]bool{}, nil)
if len(got) != 1 {
t.Fatalf("missing-id row should be retained, got len = %d", len(got))
}
if meta.UnknownCount != 1 || meta.FilteredCount != 0 || meta.ReturnedCount != 1 {
t.Fatalf("meta = %+v, want unknown=1 filtered=0 returned=1", meta)
}
})
t.Run("invariant fetched == returned + filtered", func(t *testing.T) {
muted := map[string]bool{"oc_a": true, "oc_b": false}
_, meta := ApplyMuteFilter(chats, "chat_id", muted, []string{"oc_c", "oc_d"})
if meta.FetchedCount != meta.ReturnedCount+meta.FilteredCount {
t.Fatalf("invariant broken: fetched=%d, returned=%d, filtered=%d",
meta.FetchedCount, meta.ReturnedCount, meta.FilteredCount)
}
})
}
func TestExtractChatIDs(t *testing.T) {
t.Run("dedupes and preserves order", func(t *testing.T) {
chats := []map[string]interface{}{
{"chat_id": "oc_a"},
{"chat_id": "oc_b"},
{"chat_id": "oc_a"},
{"chat_id": ""},
{"name": "no_id"},
{"chat_id": "oc_c"},
}
got := ExtractChatIDs(chats, "chat_id")
want := []string{"oc_a", "oc_b", "oc_c"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ExtractChatIDs() = %v, want %v", got, want)
}
})
t.Run("empty input yields empty slice", func(t *testing.T) {
got := ExtractChatIDs(nil, "chat_id")
if len(got) != 0 {
t.Fatalf("ExtractChatIDs(nil) = %v, want empty", got)
}
})
}
func TestMuteFilterMetaToMap(t *testing.T) {
wantKeys := []string{"applied", "fetched_count", "returned_count", "filtered_count", "hint"}
t.Run("active filter exposes exactly 5 fields", func(t *testing.T) {
meta := MuteFilterMeta{
Applied: "exclude_muted",
FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2,
Hint: "test hint",
}
got := MuteFilterMetaToMap(meta)
if got["applied"] != "exclude_muted" ||
got["fetched_count"] != 20 || got["returned_count"] != 19 ||
got["filtered_count"] != 1 || got["hint"] != "test hint" {
t.Fatalf("MuteFilterMetaToMap() = %v", got)
}
assertExactKeys(t, got, wantKeys)
})
t.Run("skipped path: hint carries the skip explanation, no extra fields", func(t *testing.T) {
meta := MuteFilterMeta{
Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonBotIdentity,
FetchedCount: 5, ReturnedCount: 5, Hint: "skipped hint",
}
got := MuteFilterMetaToMap(meta)
if got["hint"] != "skipped hint" {
t.Fatalf("hint = %v, want \"skipped hint\"", got["hint"])
}
assertExactKeys(t, got, wantKeys)
})
}
// assertExactKeys fails the test if got has any keys outside want, or is missing any.
func assertExactKeys(t *testing.T, got map[string]interface{}, want []string) {
t.Helper()
wantSet := make(map[string]struct{}, len(want))
for _, k := range want {
wantSet[k] = struct{}{}
if _, ok := got[k]; !ok {
t.Errorf("missing required key %q", k)
}
}
for k := range got {
if _, ok := wantSet[k]; !ok {
t.Errorf("unexpected key %q in MuteFilterMetaToMap output (got %v)", k, got)
}
}
}
// runtimeForOrchestrator builds a minimal RuntimeContext for testing the
// branches of MaybeApplyMuteFilter that do NOT call the underlying API.
func runtimeForOrchestrator(t *testing.T) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestMaybeApplyMuteFilter_NotEnabled(t *testing.T) {
rt := runtimeForOrchestrator(t)
chats := []map[string]interface{}{{"chat_id": "oc_a"}}
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
ExcludeMuted: false,
Chats: chats,
ChatIDKey: "chat_id",
})
if err != nil {
t.Fatalf("err = %v", err)
}
if len(out.Chats) != 1 || out.Meta.Applied != "" {
t.Fatalf("expected pass-through, got chats=%v meta.applied=%q", out.Chats, out.Meta.Applied)
}
}
func TestMaybeApplyMuteFilter_BotIdentity(t *testing.T) {
rt := runtimeForOrchestrator(t)
chats := []map[string]interface{}{
{"chat_id": "oc_a"},
{"chat_id": "oc_b"},
}
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
ExcludeMuted: true,
IsBot: true,
Chats: chats,
ChatIDKey: "chat_id",
HasMore: false,
})
if err != nil {
t.Fatalf("err = %v", err)
}
if len(out.Chats) != 2 {
t.Fatalf("bot skip should retain all chats, got %d", len(out.Chats))
}
if !out.Meta.Skipped {
t.Fatalf("skipped should be true, got meta=%+v", out.Meta)
}
if out.Meta.SkipReason != SkipReasonBotIdentity {
t.Fatalf("skip_reason = %v", out.Meta.SkipReason)
}
wantHint := "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter."
if out.Meta.Hint != wantHint {
t.Fatalf("hint = %q", out.Meta.Hint)
}
}
func TestMaybeApplyMuteFilter_PreSkipAllNonMember(t *testing.T) {
rt := runtimeForOrchestrator(t)
chats := []map[string]interface{}{
{"chat_id": "oc_a"},
{"chat_id": "oc_b"},
}
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
ExcludeMuted: true,
IsBot: false,
PreSkipReason: SkipReasonAllNonMember,
Chats: chats,
ChatIDKey: "chat_id",
HasMore: true,
})
if err != nil {
t.Fatalf("err = %v", err)
}
if len(out.Chats) != 2 {
t.Fatalf("pre-skip should retain all chats, got %d", len(out.Chats))
}
if !out.Meta.Skipped || out.Meta.SkipReason != SkipReasonAllNonMember {
t.Fatalf("meta = %+v", out.Meta)
}
wantHint := "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more."
if out.Meta.Hint != wantHint {
t.Fatalf("hint = %q", out.Meta.Hint)
}
}
func TestMaybeApplyMuteFilter_EmptyPage(t *testing.T) {
rt := runtimeForOrchestrator(t)
out, err := MaybeApplyMuteFilter(rt, MuteFilterInput{
ExcludeMuted: true,
Chats: nil,
ChatIDKey: "chat_id",
})
if err != nil {
t.Fatalf("err = %v", err)
}
if len(out.Chats) != 0 {
t.Fatalf("expected empty out, got %v", out.Chats)
}
if out.Meta.Applied != "exclude_muted" {
t.Fatalf("meta.applied = %q, want exclude_muted", out.Meta.Applied)
}
if out.Meta.FetchedCount != 0 || out.Meta.ReturnedCount != 0 || out.Meta.FilteredCount != 0 {
t.Fatalf("counts should all be zero, got meta=%+v", out.Meta)
}
if out.Meta.Skipped {
t.Fatalf("empty page is not 'skipped', got meta.skipped=%v", out.Meta.Skipped)
}
}
func TestFetchMuteStatus_OverLimit(t *testing.T) {
rt := runtimeForOrchestrator(t)
ids := make([]string, MaxMuteStatusBatchSize+1)
for i := range ids {
ids[i] = fmt.Sprintf("oc_%d", i)
}
_, _, err := FetchMuteStatus(rt, ids)
if err == nil {
t.Fatalf("expected error on over-limit batch")
}
}
func TestFetchMuteStatus_Empty(t *testing.T) {
rt := runtimeForOrchestrator(t)
muted, unknown, err := FetchMuteStatus(rt, nil)
if err != nil {
t.Fatalf("err = %v", err)
}
if len(muted) != 0 || len(unknown) != 0 {
t.Fatalf("expected empty results, got muted=%v unknown=%v", muted, unknown)
}
}

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ImChatCreate,
ImChatList,
ImChatMessageList,
ImChatSearch,
ImChatUpdate,

View File

@@ -111,7 +111,7 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
}
}
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T) {
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndUpgradeTips(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
@@ -137,11 +137,11 @@ func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T)
}
for _, want := range []string{
"Tips:",
"Agent version rule",
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
"otherwise use the default v1 flags",
"if the skill does not mention v2",
"legacy v1 examples and flags",
"Docs v1 is deprecated and will be removed soon",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
} {
if !strings.Contains(defaultHelp.String(), want) {
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String())
@@ -170,15 +170,22 @@ func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) {
for _, want := range []string{
"Document and content operations (v2).",
"Tips:",
"Agent version rule",
"otherwise use the default v1 flags",
"if the skill does not mention v2",
"legacy v1 examples and flags",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs v2 help missing %q:\n%s", want, out.String())
}
}
for _, unwanted := range []string{
"Docs v1 is deprecated and will be removed soon",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
} {
if strings.Contains(out.String(), unwanted) {
t.Fatalf("docs v2 help should not include %q:\n%s", unwanted, out.String())
}
}
}
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
@@ -255,24 +262,47 @@ func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T)
t.Fatalf("docs %s help failed: %v", tt.shortcut, err)
}
wantTips := []string{
"Tips:",
"Docs v1 is deprecated and will be removed soon",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
}
unwantedTips := []string{
"[NOTE]",
"Use --api-version v2 for the latest API",
"otherwise use the default v1 flags",
"legacy v1 examples and flags",
}
if tt.apiVersion == "v2" {
wantTips = []string{
"Tips:",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
}
unwantedTips = append(unwantedTips,
"Docs v1 is deprecated and will be removed soon",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
)
}
for _, want := range []string{
tt.shortcutHelp,
tt.versionedFlag,
"Tips:",
"Agent version rule",
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
"otherwise use the default v1 flags",
"if the skill does not mention v2",
"legacy v1 examples and flags",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
}
}
for _, unwanted := range []string{
"[NOTE]",
"Use --api-version v2 for the latest API",
} {
for _, want := range wantTips {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
}
}
for _, unwanted := range unwantedTips {
if strings.Contains(out.String(), unwanted) {
t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String())
}
@@ -385,7 +415,7 @@ func TestGenerateShortcutsJSON(t *testing.T) {
grouped[s.Service] = append(grouped[s.Service], entry{
Verb: verb,
Description: s.Description,
Scopes: s.ScopesForIdentity("user"),
Scopes: s.DeclaredScopesForIdentity("user"),
})
}

View File

@@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut {
WikiMove,
WikiNodeCreate,
WikiDeleteSpace,
WikiSpaceList,
WikiNodeList,
WikiNodeCopy,
}
}

View File

@@ -0,0 +1,973 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"encoding/json"
"net/http"
"net/url"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ── +space-list ──────────────────────────────────────────────────────────────
func TestWikiShortcutsIncludesSpaceListNodeListNodeCopy(t *testing.T) {
t.Parallel()
commands := map[string]bool{}
for _, s := range Shortcuts() {
commands[s.Command] = true
}
for _, want := range []string{"+space-list", "+node-list", "+node-copy"} {
if !commands[want] {
t.Errorf("Shortcuts() missing %q", want)
}
}
}
// TestWikiListShortcutsDeclareNarrowScopes pins the per-endpoint scope
// choice. The framework's preflight does exact string matching, so a broad
// scope (e.g. wiki:wiki:readonly) would wrongly reject tokens carrying only
// the narrow per-API scope that the API actually accepts.
func TestWikiListShortcutsDeclareNarrowScopes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
shortcut common.Shortcut
want []string
}{
{"+space-list", WikiSpaceList, []string{"wiki:space:retrieve"}},
{"+node-list", WikiNodeList, []string{"wiki:node:retrieve"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !reflect.DeepEqual(tc.shortcut.Scopes, tc.want) {
t.Fatalf("%s scopes = %v, want %v", tc.name, tc.shortcut.Scopes, tc.want)
}
})
}
}
func TestWikiSpaceListReturnsPaginatedSpaces(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_1",
"name": "Engineering Wiki",
"space_type": "team",
},
map[string]interface{}{
"space_id": "space_2",
"name": "Personal Library",
"space_type": "my_library",
},
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Spaces []map[string]interface{} `json:"spaces"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Meta.Count != 2 {
t.Fatalf("meta.count = %v, want 2", envelope.Meta.Count)
}
if envelope.Data.HasMore {
t.Fatalf("has_more = true, want false on natural end")
}
if envelope.Data.Spaces[0]["name"] != "Engineering Wiki" {
t.Fatalf("spaces[0].name = %v, want %q", envelope.Data.Spaces[0]["name"], "Engineering Wiki")
}
}
// ── +node-list ───────────────────────────────────────────────────────────────
func TestWikiNodeListRequiresSpaceID(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeList, []string{"+node-list", "--as", "user"}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "required") {
t.Fatalf("expected required flag error, got %v", err)
}
}
func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_node_1",
"obj_token": "docx_1",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Getting Started",
"has_child": true,
},
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_node_2",
"obj_token": "docx_2",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Architecture",
"has_child": false,
},
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Meta.Count != 2 {
t.Fatalf("meta.count = %v, want 2", envelope.Meta.Count)
}
if envelope.Data.Nodes[0]["title"] != "Getting Started" {
t.Fatalf("nodes[0].title = %v, want %q", envelope.Data.Nodes[0]["title"], "Getting Started")
}
if envelope.Data.Nodes[0]["has_child"] != true {
t.Fatalf("nodes[0].has_child = %v, want true", envelope.Data.Nodes[0]["has_child"])
}
}
func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_child",
"obj_token": "docx_child",
"obj_type": "docx",
"parent_node_token": "wik_parent",
"node_type": "origin",
"title": "Child Doc",
"has_child": false,
},
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
// Verify the correct node was returned (parent_node_token was passed correctly).
var envelope struct {
OK bool `json:"ok"`
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if len(envelope.Data.Nodes) != 1 {
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
}
if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" {
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent")
}
}
func TestWikiNodeListRejectsMyLibraryForBot(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "my_library", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "bot identity does not support --space-id my_library") {
t.Fatalf("expected my_library bot rejection, got %v", err)
}
}
func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Step 1: resolve my_library to the real space_id.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/my_library",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"space": map[string]interface{}{
"space_id": "space_personal_42",
"name": "My Library",
"space_type": "my_library",
},
},
},
})
// Step 2: list nodes in the resolved space.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_personal_42",
"node_token": "wik_personal_1",
"title": "Personal Note",
},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "my_library", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 1 {
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
}
if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" {
t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"])
}
}
// ── +node-copy ───────────────────────────────────────────────────────────────
func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy", "--space-id", "space_123", "--node-token", "wik_src", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") {
t.Fatalf("expected target validation error, got %v", err)
}
}
func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy", "--space-id", "space_123", "--node-token", "wik_src",
"--target-space-id", "space_dst", "--target-parent-node-token", "wik_parent",
"--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutually exclusive error, got %v", err)
}
}
// TestWikiNodeCopyDeclaredHighRiskWrite pins down the high-risk-write
// contract: invocation without --yes must fail with a confirmation_required
// error and must NOT issue the underlying API call. The aligned upstream
// schema flags this API as `danger: true`, and the shortcut now matches that
// risk classification.
func TestWikiNodeCopyDeclaredHighRiskWrite(t *testing.T) {
t.Parallel()
if WikiNodeCopy.Risk != "high-risk-write" {
t.Fatalf("WikiNodeCopy.Risk = %q, want %q", WikiNodeCopy.Risk, "high-risk-write")
}
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
// No HTTP stub registered — if the gate leaks, the request fires and
// httpmock errors with "no stub for POST ..." instead of the expected
// confirmation_required error, making the regression obvious.
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-space-id", "space_dst",
"--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected confirmation_required error, got %v", err)
}
}
func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_copied",
"obj_token": "docx_copied",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Architecture (Copy)",
"has_child": false,
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-space-id", "space_dst",
"--title", "Architecture (Copy)",
"--yes",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Data["node_token"] != "wik_copied" {
t.Fatalf("node_token = %v, want %q", envelope.Data["node_token"], "wik_copied")
}
if envelope.Data["space_id"] != "space_dst" {
t.Fatalf("space_id = %v, want %q", envelope.Data["space_id"], "space_dst")
}
var captured map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["target_space_id"] != "space_dst" {
t.Fatalf("captured target_space_id = %v, want %q", captured["target_space_id"], "space_dst")
}
if captured["title"] != "Architecture (Copy)" {
t.Fatalf("captured title = %v, want %q", captured["title"], "Architecture (Copy)")
}
if got := stderr.String(); !strings.Contains(got, "Copying wiki node") {
t.Fatalf("stderr = %q, want copy message", got)
}
}
func TestWikiNodeCopyCopiesNodeToTargetParent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_src",
"node_token": "wik_copied2",
"obj_token": "docx_copied2",
"obj_type": "docx",
"parent_node_token": "wik_parent_dst",
"node_type": "origin",
"title": "Architecture",
"has_child": false,
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-parent-node-token", "wik_parent_dst",
"--yes",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var captured map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["target_parent_token"] != "wik_parent_dst" {
t.Fatalf("captured target_parent_token = %v, want %q", captured["target_parent_token"], "wik_parent_dst")
}
if _, hasTitle := captured["title"]; hasTitle {
t.Fatalf("title should not be in body when --title not provided, got %v", captured)
}
}
// ── +space-list / +node-list pagination & format ─────────────────────────────
func TestWikiSpaceListRejectsInvalidPageSize(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-size", "0", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--page-size must be between 1 and 50") {
t.Fatalf("expected page-size validation error, got %v", err)
}
}
func TestWikiSpaceListRejectsNegativePageLimit(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-limit", "-1", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--page-limit must be a non-negative integer") {
t.Fatalf("expected page-limit validation error, got %v", err)
}
}
func TestWikiSpaceListAutoPaginatesAcrossPages(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Page 1: has_more=true, page_token set. Loop must continue.
page1 := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_page2",
"items": []interface{}{
map[string]interface{}{"space_id": "sp_1", "name": "First"},
},
},
},
}
// Page 2: must receive page_token=tok_page2 in query. Captured to verify.
var page2Query string
page2 := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
OnMatch: func(req *http.Request) { page2Query = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"space_id": "sp_2", "name": "Second"},
},
},
},
}
reg.Register(page1)
reg.Register(page2)
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--page-all", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Spaces []map[string]interface{} `json:"spaces"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 2 || len(envelope.Data.Spaces) != 2 {
t.Fatalf("merged spaces = %d / count=%v, want 2 / 2", len(envelope.Data.Spaces), envelope.Meta.Count)
}
if envelope.Data.HasMore || envelope.Data.PageToken != "" {
t.Fatalf("natural end should clear has_more/page_token, got has_more=%v page_token=%q", envelope.Data.HasMore, envelope.Data.PageToken)
}
q, _ := url.ParseQuery(page2Query)
if q.Get("page_token") != "tok_page2" {
t.Fatalf("page2 page_token = %q, want tok_page2", q.Get("page_token"))
}
}
func TestWikiSpaceListPageLimitTruncatesAndExposesNextCursor(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Only stub page 1; with --page-limit=1, the loop must stop BEFORE
// requesting page 2 — and surface has_more/page_token so the caller can resume.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"items": []interface{}{
map[string]interface{}{"space_id": "sp_only", "name": "First"},
},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-all", "--page-limit", "1", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Spaces []map[string]interface{} `json:"spaces"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if len(envelope.Data.Spaces) != 1 {
t.Fatalf("spaces = %d, want 1 (capped)", len(envelope.Data.Spaces))
}
if !envelope.Data.HasMore || envelope.Data.PageToken != "tok_next" {
t.Fatalf("truncated state = has_more=%v page_token=%q, want true / tok_next", envelope.Data.HasMore, envelope.Data.PageToken)
}
}
func TestWikiSpaceListExplicitPageTokenStopsAfterOnePage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Stub a page where has_more=true; auto-pagination should NOT trigger
// because the caller supplied an explicit --page-token cursor.
var capturedQuery string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
OnMatch: func(req *http.Request) { capturedQuery = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"items": []interface{}{map[string]interface{}{"space_id": "sp_x"}},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-token", "tok_input", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
q, _ := url.ParseQuery(capturedQuery)
if q.Get("page_token") != "tok_input" {
t.Fatalf("captured page_token = %q, want tok_input", q.Get("page_token"))
}
}
func TestWikiSpaceListPrettyFormatRendersFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "sp_1",
"name": "Engineering",
"description": "team docs",
"space_type": "team",
"visibility": "public",
"open_sharing": "open",
},
},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--format", "pretty", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
for _, want := range []string{
"Engineering",
"space_id: sp_1",
"space_type: team",
"visibility: public",
"description: team docs",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}
func TestWikiNodeListDefaultIsSinglePage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Only one stub registered; if the default tried to auto-paginate, the
// loop would attempt a 2nd request and httpmock would error. So this
// test pins down the "default = single page" contract.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"items": []interface{}{
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if len(envelope.Data.Nodes) != 1 {
t.Fatalf("nodes = %d, want 1 (single page default)", len(envelope.Data.Nodes))
}
if !envelope.Data.HasMore || envelope.Data.PageToken != "tok_next" {
t.Fatalf("single-page default should surface upstream cursor, got has_more=%v page_token=%q", envelope.Data.HasMore, envelope.Data.PageToken)
}
}
func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_1",
"obj_type": "docx",
"obj_token": "docx_1",
"title": "Getting Started",
"has_child": true,
},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
for _, want := range []string{
"Getting Started",
"node_token: wik_1",
"obj_type: docx",
"has_child: true",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}
// ── QA-driven fixes: empty slice + has_more hint + node-copy format ──
func TestWikiSpaceListEmptyResultReturnsEmptySliceNotNull(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
// Substring assertion is the only reliable way to distinguish [] from null
// in serialised JSON — unmarshalling both back into a Go slice would
// collapse the distinction.
if !strings.Contains(stdout.String(), `"spaces": []`) {
t.Fatalf("expected spaces to be empty array [], got:\n%s", stdout.String())
}
if strings.Contains(stdout.String(), `"spaces": null`) {
t.Fatalf("spaces serialised as null — JSON consumers expect []:\n%s", stdout.String())
}
var envelope struct {
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 0 {
t.Fatalf("meta.count = %v, want 0", envelope.Meta.Count)
}
}
func TestWikiSpaceListPrettyHintsWhenEmptyButHasMore(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_more",
"items": []interface{}{},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--format", "pretty", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
// When the bot's first page is filtered out by upstream permissions, the
// blanket "No wiki spaces found." used to mislead users into thinking they
// had no access at all. Pretty mode must now distinguish that case.
if strings.Contains(out, "No wiki spaces found.") {
t.Fatalf("pretty output should not flatly claim 'No wiki spaces found.' when has_more=true; got:\n%s", out)
}
for _, want := range []string{
"Current page is empty but the server reports more pages.",
"tok_more",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}
func TestWikiNodeCopyHasFormatPrettyRendersNode(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_copied",
"obj_token": "docx_copied",
"obj_type": "docx",
"parent_node_token": "wik_parent",
"node_type": "origin",
"title": "Architecture (Copy)",
},
},
},
})
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-space-id", "space_dst",
"--title", "Architecture (Copy)",
"--format", "pretty",
"--yes",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
for _, want := range []string{
"Copied node:",
"title: Architecture (Copy)",
"node_token: wik_copied",
"space_id: space_dst",
"parent_node_token: wik_parent",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// WikiNodeCopy copies a wiki node into a target space or under a target parent node.
var WikiNodeCopy = common.Shortcut{
Service: "wiki",
Command: "+node-copy",
Description: "Copy a wiki node to a target space or parent node",
Risk: "high-risk-write",
Scopes: []string{"wiki:node:copy"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "space-id", Desc: "source wiki space ID", Required: true},
{Name: "node-token", Desc: "source node token to copy", Required: true},
{Name: "target-space-id", Desc: "target wiki space ID; required if --target-parent-node-token is not set"},
{Name: "target-parent-node-token", Desc: "target parent node token; required if --target-space-id is not set"},
{Name: "title", Desc: "new title for the copied node; leave empty to keep the original title"},
},
Tips: []string{
"At least one of --target-space-id or --target-parent-node-token must be provided.",
"Omit --title to keep the original node title in the copy.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("space-id")), "--space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("node-token")), "--node-token"); err != nil {
return err
}
targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id"))
targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token"))
if targetSpaceID == "" && targetParent == "" {
return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required")
}
if targetSpaceID != "" && targetParent != "" {
return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one")
}
if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil {
return err
}
return validateOptionalResourceName(targetParent, "--target-parent-node-token")
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
nodeToken := strings.TrimSpace(runtime.Str("node-token"))
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(nodeToken))).
Body(buildNodeCopyBody(runtime)).
Set("space_id", spaceID).
Set("node_token", nodeToken)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
nodeToken := strings.TrimSpace(runtime.Str("node-token"))
fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n",
common.MaskToken(nodeToken), common.MaskToken(spaceID))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(nodeToken)),
nil, buildNodeCopyBody(runtime))
if err != nil {
return err
}
node, err := parseWikiNodeRecord(common.GetMap(data, "node"))
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Copied to node %s in space %s\n",
common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID))
out := wikiNodeCopyOutput(node)
runtime.OutFormat(out, nil, func(w io.Writer) {
renderWikiNodeCopyPretty(w, out)
})
return nil
},
}
func renderWikiNodeCopyPretty(w io.Writer, out map[string]interface{}) {
fmt.Fprintf(w, "Copied node:\n")
fmt.Fprintf(w, " title: %s\n", valueOrDash(out["title"]))
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(out["node_token"]))
fmt.Fprintf(w, " space_id: %s\n", valueOrDash(out["space_id"]))
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(out["obj_type"]))
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(out["obj_token"]))
if parent, _ := out["parent_node_token"].(string); parent != "" {
fmt.Fprintf(w, " parent_node_token: %s\n", parent)
}
}
func buildNodeCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
// Validate has already rejected the case where both --target-space-id and
// --target-parent-node-token are set (mutually exclusive). It is safe to
// inline both flags here; do not loosen that check without revisiting this
// body builder, or the upstream API will see an ambiguous request shape.
body := map[string]interface{}{}
if v := strings.TrimSpace(runtime.Str("target-space-id")); v != "" {
body["target_space_id"] = v
}
if v := strings.TrimSpace(runtime.Str("target-parent-node-token")); v != "" {
body["target_parent_token"] = v
}
if v := strings.TrimSpace(runtime.Str("title")); v != "" {
body["title"] = v
}
return body
}
func wikiNodeCopyOutput(node *wikiNodeRecord) map[string]interface{} {
return map[string]interface{}{
"space_id": node.SpaceID,
"node_token": node.NodeToken,
"obj_token": node.ObjToken,
"obj_type": node.ObjType,
"node_type": node.NodeType,
"title": node.Title,
"parent_node_token": node.ParentNodeToken,
"has_child": node.HasChild,
}
}

View File

@@ -413,6 +413,25 @@ func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) {
return "", output.ErrValidation("personal document library was not found, please specify --space-id")
}
// resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns
// the per-user real space_id. Shared by shortcuts that accept the my_library
// alias (e.g. +node-create, +node-list) so the behavior stays consistent.
func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) {
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)),
nil, nil,
)
if err != nil {
return "", err
}
space, err := parseWikiSpaceRecord(common.GetMap(data, "space"))
if err != nil {
return "", err
}
return requireWikiSpaceID(space)
}
func validateOptionalResourceName(value, flagName string) error {
if value == "" {
return nil

View File

@@ -111,8 +111,8 @@ func TestWikiShortcutsIncludeAllCommands(t *testing.T) {
t.Parallel()
shortcuts := Shortcuts()
if len(shortcuts) != 3 {
t.Fatalf("len(Shortcuts()) = %d, want 3", len(shortcuts))
if len(shortcuts) != 6 {
t.Fatalf("len(Shortcuts()) = %d, want 6", len(shortcuts))
}
if shortcuts[0].Command != "+move" {
t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move")

View File

@@ -0,0 +1,218 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
wikiNodeListDefaultPageSize = 50
wikiNodeListMaxPageSize = 50
)
// WikiNodeList lists child nodes in a wiki space or under a parent node.
var WikiNodeList = common.Shortcut{
Service: "wiki",
Command: "+node-list",
Description: "List wiki nodes in a space or under a parent node",
Risk: "read",
// Same exact-match-scope reasoning as +space-list: declare the
// narrowest scope the upstream API accepts so we don't false-reject
// tokens that only carry wiki:node:retrieve.
Scopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library, or +space-list to discover other space IDs", Required: true},
{Name: "parent-node-token", Desc: "parent node token; if omitted, lists the root-level nodes of the space"},
{Name: "page-size", Type: "int", Default: strconv.Itoa(wikiNodeListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", wikiNodeListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
},
Tips: []string{
"Default fetches a single page; pass --page-all to walk every page (large knowledge bases can be huge — keep an eye on --page-limit).",
"Use --parent-node-token to drill into a sub-directory.",
"Run +space-list first to discover your space IDs, including the personal document library.",
"--space-id my_library is a per-user alias and is only valid with --as user.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
// my_library is a per-user personal-library alias; it has no meaning
// for a tenant_access_token (--as bot), so reject early with a clear
// hint instead of deferring to API-time errors. Matches the contract
// used by +node-create and +move.
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
}
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("parent-node-token")), "--parent-node-token"); err != nil {
return err
}
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
params["parent_node_token"] = pt
}
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
}
d := common.NewDryRunAPI()
if wikiListShouldAutoPaginate(runtime) {
d.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
// When the caller passes my_library, +node-list must first resolve it
// to the real per-user space_id before listing nodes, mirroring the
// two-step orchestration used by +node-create.
if spaceID == wikiMyLibrarySpaceID {
return d.
Desc("2-step orchestration: resolve my_library -> list nodes").
GET("/open-apis/wiki/v2/spaces/my_library").
Desc("[1] Resolve my_library space ID").
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", "<resolved_space_id>")).
Desc("[2] List nodes").
Params(params).
Set("space_id", "<resolved_space_id>")
}
return d.
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
Params(params).
Set("space_id", spaceID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
spaceID := strings.TrimSpace(runtime.Str("space-id"))
// Resolve the my_library alias to the per-user real space_id before
// listing, so the subsequent request hits a concrete space endpoint.
if spaceID == wikiMyLibrarySpaceID {
resolved, err := resolveMyLibrarySpaceID(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved my_library to space %s\n", common.MaskToken(resolved))
spaceID = resolved
}
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d node(s)\n", len(nodes))
outData := map[string]interface{}{
"nodes": nodes,
"has_more": hasMore,
"page_token": nextToken,
}
runtime.OutFormat(outData, &output.Meta{Count: len(nodes)}, func(w io.Writer) {
renderWikiNodesPretty(w, nodes, hasMore, nextToken)
})
return nil
},
}
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
pageSize := runtime.Int("page-size")
startToken := strings.TrimSpace(runtime.Str("page-token"))
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
auto := wikiListShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
apiPath := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))
// Non-nil empty slice keeps json output stable as `[]` instead of `null`.
var (
nodes = make([]map[string]interface{}, 0)
pageToken = startToken
lastHasMore bool
lastPageToken string
)
for page := 0; ; page++ {
params := map[string]interface{}{"page_size": pageSize}
if parentNodeToken != "" {
params["parent_node_token"] = parentNodeToken
}
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", apiPath, params, nil)
if err != nil {
return nil, false, "", err
}
items, _ := data["items"].([]interface{})
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
nodes = append(nodes, wikiNodeListItem(m))
}
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !auto {
break
}
if !lastHasMore || lastPageToken == "" {
break
}
if pageLimit > 0 && page+1 >= pageLimit {
break
}
pageToken = lastPageToken
}
return nodes, lastHasMore, lastPageToken, nil
}
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"space_id": common.GetString(m, "space_id"),
"node_token": common.GetString(m, "node_token"),
"obj_token": common.GetString(m, "obj_token"),
"obj_type": common.GetString(m, "obj_type"),
"parent_node_token": common.GetString(m, "parent_node_token"),
"node_type": common.GetString(m, "node_type"),
"title": common.GetString(m, "title"),
"has_child": common.GetBool(m, "has_child"),
}
}
func renderWikiNodesPretty(w io.Writer, nodes []map[string]interface{}, hasMore bool, pageToken string) {
if len(nodes) == 0 {
if hasMore && pageToken != "" {
fmt.Fprintln(w, "Current page is empty but the server reports more pages.")
fmt.Fprintln(w, "Pass --page-all to walk every page, or --page-token to resume from the cursor below:")
fmt.Fprintf(w, " next page_token: %s\n", pageToken)
return
}
fmt.Fprintln(w, "No wiki nodes found.")
return
}
for i, n := range nodes {
fmt.Fprintf(w, "[%d] %s\n", i+1, valueOrDash(n["title"]))
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(n["node_token"]))
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(n["obj_type"]))
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(n["obj_token"]))
hasChild, _ := n["has_child"].(bool)
fmt.Fprintf(w, " has_child: %t\n", hasChild)
if parent, _ := n["parent_node_token"].(string); parent != "" {
fmt.Fprintf(w, " parent: %s\n", parent)
}
fmt.Fprintln(w)
}
if hasMore && pageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", pageToken)
}
}

View File

@@ -0,0 +1,211 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
wikiSpaceListAPIPath = "/open-apis/wiki/v2/spaces"
wikiSpaceListDefaultPageSize = 50
wikiSpaceListMaxPageSize = 50
)
// WikiSpaceList lists all wiki spaces the caller has access to.
var WikiSpaceList = common.Shortcut{
Service: "wiki",
Command: "+space-list",
Description: "List wiki spaces accessible to the caller",
Risk: "read",
// Declare the narrowest valid scope: the upstream API accepts any of
// wiki:wiki / wiki:wiki:readonly / wiki:space:retrieve, but the
// framework's preflight does exact-string scope matching (see
// internal/auth/scope.go), so picking the broad readonly form would
// wrongly reject tokens that only carry the narrow retrieve scope and
// hand them a misleading missing-scope hint.
Scopes: []string{"wiki:space:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: strconv.Itoa(wikiSpaceListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", wikiSpaceListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
},
Tips: []string{
"Default fetches a single page (matches other list shortcuts in this CLI); pass --page-all to pull every page.",
"The underlying API never returns the my_library personal library; resolve it via `wiki spaces get --params '{\"space_id\":\"my_library\"}'`.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateWikiListPagination(runtime, wikiSpaceListMaxPageSize)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
}
dry := common.NewDryRunAPI()
// Auto-pagination is the default — make it explicit in the dry-run so
// callers can see whether the loop will fire.
if wikiListShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
return dry.GET(wikiSpaceListAPIPath).Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
spaces, hasMore, nextToken, err := fetchWikiSpaces(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d wiki space(s)\n", len(spaces))
outData := map[string]interface{}{
"spaces": spaces,
"has_more": hasMore,
"page_token": nextToken,
}
runtime.OutFormat(outData, &output.Meta{Count: len(spaces)}, func(w io.Writer) {
renderWikiSpacesPretty(w, spaces, hasMore, nextToken)
})
return nil
},
}
// fetchWikiSpaces honours the four pagination flags:
// - default (no --page-all, no --page-token): fetch a single page from the start
// - --page-token X: fetch a single page starting at X (auto-pagination disabled)
// - --page-all: pull subsequent pages, capped by --page-limit (default 10; 0 = unlimited)
//
// The returned slice is always non-nil so json output stays as `[]` instead of `null`.
func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{}, bool, string, error) {
pageSize := runtime.Int("page-size")
startToken := strings.TrimSpace(runtime.Str("page-token"))
auto := wikiListShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
var (
spaces = make([]map[string]interface{}, 0)
pageToken = startToken
lastHasMore bool
lastPageToken string
)
for page := 0; ; page++ {
params := map[string]interface{}{"page_size": pageSize}
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", wikiSpaceListAPIPath, params, nil)
if err != nil {
return nil, false, "", err
}
items, _ := data["items"].([]interface{})
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
spaces = append(spaces, parseWikiSpaceItem(m))
}
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !auto {
break
}
if !lastHasMore || lastPageToken == "" {
break
}
if pageLimit > 0 && page+1 >= pageLimit {
break
}
pageToken = lastPageToken
}
return spaces, lastHasMore, lastPageToken, nil
}
func parseWikiSpaceItem(m map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"space_id": common.GetString(m, "space_id"),
"name": common.GetString(m, "name"),
"description": common.GetString(m, "description"),
"space_type": common.GetString(m, "space_type"),
"visibility": common.GetString(m, "visibility"),
"open_sharing": common.GetString(m, "open_sharing"),
}
}
func renderWikiSpacesPretty(w io.Writer, spaces []map[string]interface{}, hasMore bool, pageToken string) {
if len(spaces) == 0 {
// Distinguish "nothing here" from "current page empty but server says
// more pages follow" — the latter is a hint to keep paginating instead
// of giving up.
if hasMore && pageToken != "" {
fmt.Fprintln(w, "Current page is empty but the server reports more pages.")
fmt.Fprintln(w, "Pass --page-all to walk every page, or --page-token to resume from the cursor below:")
fmt.Fprintf(w, " next page_token: %s\n", pageToken)
return
}
fmt.Fprintln(w, "No wiki spaces found.")
return
}
for i, s := range spaces {
fmt.Fprintf(w, "[%d] %s\n", i+1, valueOrDash(s["name"]))
fmt.Fprintf(w, " space_id: %s\n", valueOrDash(s["space_id"]))
fmt.Fprintf(w, " space_type: %s\n", valueOrDash(s["space_type"]))
fmt.Fprintf(w, " visibility: %s\n", valueOrDash(s["visibility"]))
fmt.Fprintf(w, " open_sharing: %s\n", valueOrDash(s["open_sharing"]))
if desc, _ := s["description"].(string); desc != "" {
fmt.Fprintf(w, " description: %s\n", desc)
}
fmt.Fprintln(w)
}
if hasMore && pageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", pageToken)
}
}
func valueOrDash(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}
// validateWikiListPagination performs flag-level validation shared by
// +space-list and +node-list.
func validateWikiListPagination(runtime *common.RuntimeContext, maxPageSize int) error {
if n := runtime.Int("page-size"); n < 1 || n > maxPageSize {
return common.FlagErrorf("--page-size must be between 1 and %d", maxPageSize)
}
if n := runtime.Int("page-limit"); n < 0 {
return common.FlagErrorf("--page-limit must be a non-negative integer")
}
return nil
}
// wikiListShouldAutoPaginate reports whether the fetch loop should keep
// requesting additional pages. An explicit --page-token disables auto loop
// because the caller has supplied a specific cursor.
func wikiListShouldAutoPaginate(runtime *common.RuntimeContext) bool {
if strings.TrimSpace(runtime.Str("page-token")) != "" {
return false
}
return runtime.Bool("page-all")
}
// warnIfConflictingPagingFlags logs a notice when --page-token and --page-all
// are both set. --page-token wins (single-page fetch from the supplied cursor)
// and --page-all is silently ignored, which would otherwise look like a bug to
// callers expecting subsequent pages to be drained.
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
fmt.Fprintln(runtime.IO().ErrOut,
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
}
}

View File

@@ -86,8 +86,8 @@ Drive Folder (云空间文件夹)
## 重要说明:画板编辑
> **⚠️ lark-doc skill 不能直接编辑已有画板内容,但 `docs +update` 可以新建空白画板**
### 场景 1已通过 docs +fetch 获取到文档内容和画板 token
如果用户已经通过 `docs +fetch` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
### 场景 1已通过 docs +fetch --api-version v2 获取到文档内容和画板 token
如果用户已经通过 `docs +fetch --api-version v2` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户:
1. 记录画板的 token
2. 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容
### 场景 2刚创建画板需要编辑
@@ -115,4 +115,4 @@ Drive Folder (云空间文件夹)
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
## 补充说明
`docs +search` 除了搜索文档 / Wiki也承担“先定位云空间对象再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
`docs +search` 除了搜索文档 / Wiki也承担“先定位云空间对象再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。

View File

@@ -101,7 +101,7 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |

View File

@@ -35,6 +35,8 @@ lark-cli approval <resource> <method> [flags] # 调用 API
- `reject` — 拒绝审批任务
- `transfer` — 转交审批任务
- `query` — 查询用户的任务列表
- `add_sign` — 审批任务加签
- `rollback` — 退回审批任务
## 权限表
@@ -49,4 +51,6 @@ lark-cli approval <resource> <method> [flags] # 调用 API
| `tasks.reject` | `approval:task:write` |
| `tasks.transfer` | `approval:task:write` |
| `tasks.query` | `approval:task:read` |
| `tasks.add_sign` | `approval:task:write` |
| `tasks.rollback` | `approval:task:write` |

View File

@@ -1,7 +1,6 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档v2创建和编辑飞书文档。使用本 skill 时docs +create、docs +fetch、docs +update 必须携带 --api-version v2默认使用 DocxXML 格式(也支持 Markdown。创建文档、获取文档内容支持 simple/with-ids/full 三种导出详细度,以及 full/outline/range/keyword/section 五种局部读取模式可按目录、block id 区间、关键词或标题自动成节只拉部分内容以节省上下文、更新文档八种指令str_replace/block_insert_after/block_copy_insert_after/block_replace/block_delete/block_move_after/overwrite/append、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用如果用户是想按名称或关键词先定位电子表格、报表等云空间对象也优先使用本 skill 的 docs +search 做资源发现。"
description: "飞书云文档 / Docx / 知识库 Wiki 文档v2创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token再切到对应 skill 下钻。使用本 skill 时docs +create、docs +fetch、docs +update 必须携带 --api-version v2默认使用 DocxXML也支持 Markdown。"
metadata:
requires:
bins: ["lark-cli"]
@@ -10,7 +9,7 @@ metadata:
# docs (v2)
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create`、`docs +fetch`、`docs +update` 命令必须携带 `--api-version v2`。**
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create --api-version v2`、`docs +fetch --api-version v2`、`docs +update --api-version v2` 命令必须携带 `--api-version v2`。**
```bash
# 常用示例
@@ -23,7 +22,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
2. **读取文档(`docs +fetch --api-version v2`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
**未读完以上文件就执行相应操作会导致参数选择错误、格式错误或样式不达标。**
@@ -34,6 +33,8 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
## 快速决策
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 写文档时,重要信息(核心流程、架构、对比、风险、路线图、关键指标、因果关系)优先规划为画板,不要只用文字或表格承载
- 新增画板必须隔离到 SubAgent简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
@@ -49,7 +50,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
**补充:** 云空间资源发现统一走 [`drive +search`](../lark-drive/references/lark-drive-search.md);当用户口头说"表格/报表/最近我编辑过的 xxx"时,也优先从 `drive +search` 开始。老的 `docs +search` 只在沿用 `--filter` JSON 的存量脚本里保留,后续会下线。

View File

@@ -130,7 +130,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
## 嵌入电子表格 / 多维表格
返回中可能含 `<sheet>``<bitable>``<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
返回中可能含 `<sheet>``<bitable>``<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch --api-version v2` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
## 参考

View File

@@ -1,6 +1,6 @@
# Markdown 格式参考
`docs +fetch / +create / +update` 使用 `--doc-format markdown` 时适用。
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。
## 转义规则
@@ -34,14 +34,14 @@
- `$...$` 数学公式内部,符号为 LaTeX 语法,不受 Markdown 转义影响
**导出已转义,不要反转义:**
`docs +fetch --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[``\|``\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
`docs +fetch --api-version v2 --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[``\|``\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
**写入时必须转义:**
使用 `docs +create``docs +update``--doc-format markdown` 写入内容时,字面文本中的特殊字符同样必须转义。`--pattern` 参数中也必须使用转义形式才能正确匹配。
**导出 → 更新 工作流示例:**
1. `docs +fetch` 导出得到 `C:\\Users\\test\[1\]`
1. `docs +fetch --api-version v2` 导出得到 `C:\\Users\\test\[1\]`
2.`str_replace --pattern 'C:\\Users\\test\[1\]'` 匹配(直接使用导出的转义形式)
3. `--content` 中的替换内容也要保持转义:`C:\\Users\\prod\[2\]`

View File

@@ -67,6 +67,12 @@ lark-cli docs +media-insert --doc doxcnXXX --file ./spec.pdf --type file
# 图片对齐与描述caption
lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --align center --caption "架构图"
# Insert image with explicit display width (height auto-computed from aspect ratio)
lark-cli docs +media-insert --doc doxcnXXX --file ./banner.png --width 800 --align center
# Insert image with explicit width and height
lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --width 800 --height 447 --caption "architecture diagram"
```
## 参数
@@ -79,6 +85,8 @@ lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --align center --cap
| `--type <type>` | 否 | `image`(默认)或 `file``--from-clipboard` 目前只产出 image。 |
| `--align <align>` | 否 | 仅图片:`left` / `center`(默认)/ `right` |
| `--caption <text>` | 否 | 仅图片:图片描述 |
| `--width <px>` | 否 | Image display width in pixels (only for `--type=image`). If `--height` is omitted, it is auto-computed from the source image aspect ratio. Supported auto-detection formats: PNG, JPEG, GIF; other formats (WebP, BMP, etc.) require both `--width` and `--height`. |
| `--height <px>` | 否 | Image display height in pixels (only for `--type=image`). If `--width` is omitted, it is auto-computed from the source image aspect ratio. Supported auto-detection formats: PNG, JPEG, GIF; other formats (WebP, BMP, etc.) require both `--width` and `--height`. |
> [!IMPORTANT]
> 如果上一步是 [`lark-doc-create`](lark-doc-create.md),并且它在知识库/知识空间场景下返回的是 `/wiki/...` 形式的 `doc_url`,后续调用 `docs +media-insert` 时应优先传 `doc_id`,不要直接传这个 `doc_url`。

View File

@@ -57,7 +57,7 @@ lark-cli docs +search \
# 按文档所有者过滤creator_ids 传文档所有者 open_id不是邮箱 / user_id
lark-cli docs +search \
--query "季度总结" \
--filter '{"creator_ids":["ou_7890123456abcdef"]}'
--filter '{"creator_ids":["ou_EXAMPLE_USER_ID"]}'
# 只搜索指定类型
lark-cli docs +search \
@@ -87,7 +87,7 @@ lark-cli docs +search \
# 只搜索指定分享者分享过的文档sharer_ids 传分享者 open_id最多 20 个)
lark-cli docs +search \
--query "复盘" \
--filter '{"sharer_ids":["ou_7890123456abcdef"]}'
--filter '{"sharer_ids":["ou_EXAMPLE_USER_ID"]}'
# 按创建时间过滤并指定排序方式
lark-cli docs +search \
@@ -97,7 +97,7 @@ lark-cli docs +search \
# 组合多个筛选条件
lark-cli docs +search \
--query "项目复盘" \
--filter '{"creator_ids":["ou_7890123456abcdef"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}'
--filter '{"creator_ids":["ou_EXAMPLE_USER_ID"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}'
# 只在指定知识空间下搜 Wiki
lark-cli docs +search \
@@ -179,10 +179,10 @@ lark-cli docs +search --query "方案" --format json --page-token '<PAGE_TOKEN>'
### 常见 `--filter` JSON 片段
```json
{"creator_ids":["ou_7890123456abcdef"]}
{"creator_ids":["ou_EXAMPLE_USER_ID"]}
{"doc_types":["SHEET","DOCX"]}
{"chat_ids":["oc_1234567890abcdef"]}
{"sharer_ids":["ou_7890123456abcdef"]}
{"sharer_ids":["ou_EXAMPLE_USER_ID"]}
{"folder_tokens":["fld_123456"]}
{"only_title":true}
{"only_comment":true}

View File

@@ -15,7 +15,7 @@
> - **局部精修**`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after`):优先使用 XML默认。XML 能稳定表达 block 结构和样式,精准编辑更可控;不要因为 Markdown 写起来更简单就自行切换。
> - **整段写入**`append` / `overwrite`XML 和 Markdown 都可以。用户提供 `.md` 本地文件或明确要求 Markdown 时直接用 Markdown否则默认 XML。
>
> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID也无样式颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope``outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown只是写入内容不带样式。
> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID也无样式颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --api-version v2 --detail with-ids` **配合 `--scope``outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown只是写入内容不带样式。
## 参数
@@ -221,7 +221,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
## 画板处理
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再切到 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 用 `whiteboard +update` 写入。
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch --api-version v2` 取到 `<whiteboard token="...">`,再 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。

View File

@@ -6,46 +6,79 @@
| Skill | 核心职责 | 约束 |
|------|------|------|
| `lark-doc` | 文档内容读取/更新、插入空白画板占位、获取 board_token |直接编辑画板内容;`docs +update` 的画板能力仅限插入空白占位 |
| `lark-whiteboard` | 查询/导出画板(+query图表内容生成Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入画板(+update | 图表内容生成由此 skill 完整执行,不依赖外部调度 |
| `lark-doc` | 识别画板机会、判断简单/复杂、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容;简单图不需要读取 `lark-whiteboard` |
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅复杂图或已有画板更新时由独立 SubAgent 读取 |
## 画板优先规则
写文档时,重要信息优先画板化。遇到核心流程、系统架构、方案对比、风险链路、里程碑、指标趋势、因果归因、组织关系、能力分层等内容,不要只用段落或表格承载;除非内容只是一次性补充说明,否则应规划为画板。
同一篇文档可以有多个画板。优先多个聚焦画板,而不是把所有信息塞进一张大图。
## 文档与画板协同流程
### 步骤 1判断场景
### 步骤 1识别画板机会
| 场景 | 入口 |
|------|------|
| 文档中需要插入新画板 | 继续步骤 2 |
| 已有画板需要更新内容 | 先 `docs +fetch` 获取 `board_token`,跳至步骤 3 |
| 文档中需要插入简单新画板 | 步骤 2A |
| 文档中需要插入复杂新画板 | 走步骤 2B |
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B |
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
### 步骤 2在文档中创建空白画板
简单图判定:节点少、静态、布局可控、适合一个完整自包含 SVG 表达例如小型流程、2-3 方对比、小型状态机、简单时间线或小型示意图。
- 创建场景:`docs +create`;编辑场景:`docs +update`
- markdown 中使用 `<whiteboard type="blank"></whiteboard>`(不要转义)
- 多个画板时,在相应的地方插入各自的 whiteboard 标签
- 从响应的 `data.board_tokens` 中读取 token 列表
复杂图判定:节点多、跨泳道/跨系统、需要自动布局或精细排版、包含数据图表、组织架构、复杂架构、复杂依赖、已有画板更新,或需要 `lark-whiteboard` 的渲染验证。
### 步骤 3生成并写入画板内容
### 步骤 2A简单图 — SubAgent 直接插入 SVG 画板
读取 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md),跳至"渲染 & 写入画板"章节,按其完整流程为每个 board_token 生成并写入图表内容。
主 Agent 启动 SubAgent让它用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入:
多个画板时依次处理,每个画板完成后再处理下一个。
```xml
<whiteboard type="svg"><svg ...>...</svg></whiteboard>
```
简单图 SubAgent 的最小上下文:
- doc token、插入位置标题 / block_id / command
- 图表目标、受众、源段落或数据
- 要求读取 `lark-doc-xml.md`;不需要读取 `lark-whiteboard`
- SVG 必须完整自包含:包含 `<svg>` 根节点和 `viewBox`,不引用外部图片、脚本、远程资源
### 步骤 2B复杂图 — 先创建空白画板
- 主 Agent 使用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入 `<whiteboard type="blank"></whiteboard>`
- 从 v2 响应的 `data.document.new_blocks[]` 中读取 `block_type == "whiteboard"``block_token` 作为 board_token。
### 步骤 3B复杂图或已有画板 — 启动 lark-whiteboard SubAgent
复杂图和已有画板更新必须启动 SubAgent。主 Agent 只传最小上下文,不直接执行 `lark-whiteboard` 的渲染和写入流程。
复杂图 SubAgent 的最小上下文:
- board_token
- 图表目标、推荐画板类型、受众
- 与图表直接相关的源段落或数据
- 要求读取 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md),按其完整流程写入该 board_token
多个画板互不依赖时,可并行启动多个 SubAgent每个 SubAgent 只负责一个画板或一个 SVG 插入点,不要互相复用上下文。
### 步骤 4完成校验
- 确认每个 token 对应的画板都已填充真实内容
- 不保留空白占位画板;只有空白画板而无内容视为任务未完成
- 简单 SVG确认插入的是 `<whiteboard type="svg">`,且内容是完整 `<svg ...>...</svg>`
- 复杂画板:确认每个 token 对应的画板都已填充真实内容
- 不保留空白占位画板;复杂路径只有空白画板而无内容视为任务未完成
---
## 语义与画板类型映射
下表用于帮助主 Agent 判断简单/复杂路径,并给 SubAgent 指定推荐画板类型。
| 语义 | 画板类型 |
|------|------|
| 架构/分层/技术方案/模块依赖/调用关系 | 架构图 |
| 流程/审批/部署/业务流转/状态机 | 流程图 |
| 跨角色流程/跨系统交互/端到端链路 | 泳道图 |
| 小型流程/状态机/简单时间线/小型对比/小型示意图 | SVG 画板(简单路径) |
| 架构/分层/技术方案/模块依赖/调用关系 | 架构图(复杂路径) |
| 流程/审批/部署/业务流转/状态机 | 流程图(按复杂度分流) |
| 跨角色流程/跨系统交互/端到端链路 | 泳道图(复杂路径) |
| 组织/层级/汇报关系 | 组织架构图 |
| 时间线/里程碑/版本规划 | 里程碑图 |
| 因果/复盘/根因分析 | 鱼骨图 |
@@ -56,6 +89,7 @@
| 转化漏斗/销售漏斗 | 漏斗图 |
| 分类梳理/知识体系/思维导图/时序图/类图 | Mermaid |
| 数据分布/占比/饼图 | Mermaid |
| 简单自定义图形/小型 SVG 示意图 | SVG 画板(简单路径) |
| 柱状图/条形图/数据对比 | 柱状图 |
| 折线图/趋势图/时序数据 | 折线图 |

View File

@@ -15,7 +15,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|-|-|-|
| `<callout>` | 高亮框,子块仅支持文本、标题、列表、待办、引用 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
| `<grid>` + `<column>` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` |
| `<whiteboard>` | 嵌入画板 | `type`: `mermaid` \| `plantuml` \| `blank` |
| `<whiteboard>` | 嵌入画板 | `type`: `blank` \| `mermaid` \| `plantuml` \| `svg` |
| `<pre>` | (代码块,内含 `code`| `lang`, `caption` |
| `<figure>` | 视图容器 | `view-type` |
| `<bookmark>` | 书签链接 | `<bookmark name="标题" href="https://..."></bookmark>`,必传 name 和 href |
@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
- `<img>``<img href="https://..."/>` 上传网络图片
- `<whiteboard>``<whiteboard type="blank"></whiteboard>` 空白`<whiteboard type="mermaid|plantuml">内容</whiteboard>` 带内容
- `<whiteboard>` 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
@@ -166,4 +166,4 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
<task task-id="TASK_GUID"></task>
<chat_card chat-id="CHAT_ID"></chat_card>
```
```

View File

@@ -19,7 +19,7 @@
### 第一波 — 规划与骨架(串行)
1. 分析用户需求:受众、目的、范围
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block;承载重要信息的章节优先规划画板
3. `docs +create --api-version v2` **只建骨架**:标题 + 开头 `<callout>` + 各级标题 + 每节一句占位摘要
- ⚠️ **不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
- 完整内容留到第二波,由各 Agent 用 `docs +update --command append``block_insert_after` 分段写入。
@@ -33,13 +33,15 @@
### 第三波 — 整合审查 + 画板意图识别(串行)
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
6. 评估样式达标(富 block 密度、元素多样性、连续 `<p>` 数量)
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。记录需要插图的章节推荐画板类型
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节推荐画板类型、简单/复杂路径和用于画图的源内容
### 第四波 — 润色与图表(并行 Agent
8. Spawn Agent 定向改进:(结合 `lark-doc-style.md` 润色)
- **优先处理第三波识别出的画板需求**:简单图直接 `<whiteboard type="mermaid|plantuml">`,复杂图 spawn Agent 使用 **lark-whiteboard** skill
### 第四波 — 画板与润色(并行 Agent
8. **优先处理第三波识别出的画板需求**
- 简单图:启动 SVG SubAgent直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;不读取 **lark-whiteboard**
- 复杂图:主 Agent 先插入 `<whiteboard type="blank"></whiteboard>` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板
9. Spawn 内容改写 Agent 定向润色:
- 文字密集章节转为 `<table>`/`<grid>`/`<callout>`
- 主要章节间补充 `<hr/>`
- 本地图片使用 `docs +media-insert` 插入
@@ -47,4 +49,8 @@
## Agent 子任务要求
Spawn Agent 必须提供:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
SVG SubAgent 必须收到:文档 token、插入位置标题/block ID、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`
复杂画板 SubAgent 必须收到board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。

View File

@@ -8,26 +8,29 @@
2. **Front-load 结论**:文档以 `<callout>` 开头概括核心结论;每章节首段点明要旨
3. **视觉节奏**:连续纯文本不超过 3 段;不同主题章节间用 `<hr/>` 分隔
4. **最少惊讶**:同类信息使用同类元素,全篇风格统一
5. **重要信息画板化**:核心流程、架构、对比、风险、路线图、指标趋势等重要信息优先使用画板表达
## 二、元素选择指南
涉及图表需求时,简单图用 `<whiteboard type="mermaid/plantuml">` 内嵌,复杂图使用 **lark-whiteboard** skill
涉及图表需求时,先判定简单/复杂:简单图启动 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读取 **lark-whiteboard**复杂图使用空白画板 + **lark-whiteboard** SubAgent
| 场景 | 推荐方案 |
|-|-|
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
| 方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏 |
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
| 任务清单 / 检查项 | `<checkbox>` |
| 代码片段 | `<pre lang="x" caption="说明">` |
| 引用 / 公式 | `<blockquote>` / `<latex>` |
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
| 简单流程图 / 时序图 / 状态机 / 甘特图 | `<whiteboard type="mermaid/plantuml">` |
| 复杂架构图 / 数据图 / 思维导图 / 组织架构 | **lark-whiteboard** skill |
| 场景 | 推荐方案 |
|-|---------------------------------------------------------------|
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
| 重要方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏;简单 SVG SubAgent复杂矩阵用 lark-whiteboard SubAgent |
| 简短低风险对比 | `<grid>` 2 列分栏 |
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
| 任务清单 / 检查项 | `<checkbox>` |
| 代码片段 | `<pre lang="x" caption="说明">` |
| 引用 / 公式 | `<blockquote>` / `<latex>` |
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
| 简单流程图 / 小型状态机 / 小型时间线 | 简单 SVG SubAgent |
| 简单自定义图形 / 小型 SVG 示意图 | 简单 SVG SubAgent |
| 复杂架构图 / 数据图 / 思维导图 / 组织架构 | 空白画板 + lark-whiteboard SubAgent |
### 画板意图识别
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本:
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本;如果该内容承载章节核心结论、关键决策或主要论据,即使结构较简单也优先画板化
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
|-|-|-|
@@ -45,21 +48,27 @@
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
**判断规则:**
- 简单图(节点 ≤ 10、无需精细排版`<whiteboard type="mermaid/plantuml">` 内嵌
- 复杂图(节点 > 10、需自定义布局/样式、数据图表)→ spawn Agent 使用 **lark-whiteboard** skill
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
- 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读取 **lark-whiteboard**
- 复杂图或已有画板更新才先插入 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 使用 **lark-whiteboard** skill 写入内容
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
### 画板语法与插入
> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。
> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需启动 SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。
#### 内嵌 Mermaid / PlantUML首选
简单图直接用 `<whiteboard type="mermaid|plantuml">语法</whiteboard>`,作为 block 嵌入文档。
#### 简单 SVG 画板SubAgent 插入
#### DSL 画板Mermaid / PlantUML 不够用时)
需要架构图、对比图、组织架构等复杂结构时:
1. `<whiteboard type="blank"></whiteboard>` 通过 `docs +create` / `docs +update` 插入空白画板
2. 从响应 `data.document.new_blocks` 中提取画板 `block_token`
3. 切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 设计并上传 DSL
1. 主 Agent 启动 SubAgent传入 doc token、插入位置、图表目标和源内容
2. SubAgent 使用 `<whiteboard type="svg">完整自包含 SVG</whiteboard>` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入
3. SVG 必须包含 `<svg>` 根节点和 `viewBox`,不要引用外部图片、脚本或远程资源
#### 复杂画板(空白画板 + lark-whiteboard SubAgent
1.`<whiteboard type="blank"></whiteboard>` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入空白画板
2. 从 v2 响应 `data.document.new_blocks` 中提取画板 `block_token`
3. 必须启动 SubAgent`block_token`、图表目标、推荐画板类型和源内容交给它
4. SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 并写入该画板;主 Agent 不直接调用画板渲染流程
更完整的协同流程见 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。

View File

@@ -25,24 +25,30 @@
- 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids`
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
2. 系统性评估:结构清晰度、富 block 密度≥40%、元素多样性≥3种、连续 `<p>` 是否超过 3 段、是否有开头 callout 和章节 `<hr/>`
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。记录需要插图的章节block ID推荐画板类型
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节block ID推荐画板类型、简单/复杂路径和源内容片段
4. 向用户简要说明改进计划(包含识别出的画板机会)
### 第二波 — 定向改写(并行 Agent
5. Spawn Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID`lark-doc-style.md`
5. **优先处理第一波识别出的画板候选段落**
- 简单图:启动 SVG SubAgent直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;不读取 **lark-whiteboard**
- 复杂图:主 Agent 先插入 `<whiteboard type="blank"></whiteboard>` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID`lark-doc-style.md`
- 开头适当添加 `<callout>`、重组引言
- 纯文本转为 `<grid>`/`<table>`/`<whiteboard>`
- **对第一波识别出的画板候选段落**:简单图直接 `<whiteboard type="mermaid|plantuml">`,复杂图 spawn Agent 使用 **lark-whiteboard** skill
- 添加流程图、对比分栏等富 block
- 纯文本转为 `<grid>`/`<table>`/`<callout>`
- 添加低重要度对比分栏、关键提示等富 block画板类需求只走第 5 步
### 第三波 — 验证(串行)
5. 获取更新后文档局部内容,重新检查样式指标
6. 未达标则定向修正,向用户呈现结果
7. 获取更新后文档局部内容,重新检查样式指标
8. 未达标则定向修正,向用户呈现结果
## Agent 子任务要求
Spawn Agent 必须提供:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
SVG SubAgent 必须收到:文档 token、插入位置标题/block ID、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`
复杂画板 SubAgent 必须收到board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
**上下文节省提示**Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --api-version v2 --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。

View File

@@ -117,7 +117,7 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id`slides` 仅支持 block_id且都支持最终解析到对应类型的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
@@ -200,6 +200,19 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides |
#### `permission.public.patch` 错误码引导
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope它们通常表示租户、对外分享或文档密级策略拦截。
| 错误码 | 含义 | 给用户的引导 |
|--------|------------------------|--------------|
| `91009` | 对外分享被租户安全策略管控,当前用户无法开启 | 提示用户:对外分享能力被租户安全策略统一管控,无法通过 API 或当前用户直接开启;需要联系租户管理员调整组织级对外分享策略。 |
| `91010` | 文档对外分享未打开 | 提示用户:当前文档尚未打开对外分享,请先在文档权限设置中打开对外分享,再重试 `permission.public.patch`。 |
| `91011` | 对外分享被文档密级管控 | 提示用户:对外分享被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
| `91012` | 权限设置被文档密级管控 | 提示用户:该权限设置被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
当用户最初提供的是文档 URL遇到 `91011` 或 `91012` 时直接把该 URL 原样返回给用户作为操作入口;如果上下文只有 token需要先尽量通过已有上下文、搜索结果或元数据恢复目标文档 URL再给出可点击的文档 URL。
### 授权当前应用访问文档
当需要将文档权限授予**当前应用bot自身**时,先通过 bot info 接口获取应用的 open_id再调用权限接口授权
@@ -229,8 +242,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Duplicate remote `rel_path` conflicts fail by default before writing; for duplicate files only, `--on-duplicate-remote rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` explicitly choose one. Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. |
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact` or `detection=quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |
@@ -238,7 +251,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Duplicate remote `rel_path` conflicts fail by default before upload / overwrite / delete; use `--on-duplicate-remote newest\|oldest` only when the conflict is duplicate files and you explicitly want to target one existing remote file. Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. |
| [`+push`](references/lark-drive-push.md) | File-level local → Drive mirror. Duplicate remote `rel_path` conflicts fail by default; `newest` / `oldest` only apply to duplicate files when you explicitly want to target one remote file. `--if-exists` supports `skip` / `smart` / `overwrite` (`smart` skips files whose remote `modified_time` is already up to date, but falls through to the same overwrite path when the remote is older, so it inherits overwrite's rollout caveat). `--delete-remote` requires `--yes`. `--local-dir` must stay inside cwd. |
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
@@ -302,27 +315,29 @@ lark-cli drive <resource> <method> [flags] # 调用 API
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `files.copy` | `docs:document:copy` |
| `files.create_folder` | `space:folder:create` |
| `files.list` | `space:document:retrieve` |
| `files.patch` | `docx:document:write_only` |
| `file.comments.batch_query` | `docs:document.comment:read` |
| `file.comments.create_v2` | `docs:document.comment:create` |
| `file.comments.list` | `docs:document.comment:read` |
| `file.comments.patch` | `docs:document.comment:update` |
| `file.comment.replys.create` | `docs:document.comment:create` |
| `file.comment.replys.delete` | `docs:document.comment:delete` |
| `file.comment.replys.list` | `docs:document.comment:read` |
| `file.comment.replys.update` | `docs:document.comment:update` |
| `permission.members.auth` | `docs:permission.member:auth` |
| `permission.members.create` | `docs:permission.member:create` |
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
| `metas.batch_query` | `drive:drive.metadata:readonly` |
| `user.remove_subscription` | `docs:event:subscribe` |
| `user.subscription` | `docs:event:subscribe` |
| `user.subscription_status` | `docs:event:subscribe` |
| `file.statistics.get` | `drive:drive.metadata:readonly` |
| `file.view_records.list` | `drive:file:view_record:readonly` |
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
| 方法 | 所需 scope |
|------------------------------------------------|-----------------------------------|
| `files.copy` | `docs:document:copy` |
| `files.create_folder` | `space:folder:create` |
| `files.list` | `space:document:retrieve` |
| `files.patch` | `docx:document:write_only` |
| `file.comments.batch_query` | `docs:document.comment:read` |
| `file.comments.create_v2` | `docs:document.comment:create` |
| `file.comments.list` | `docs:document.comment:read` |
| `file.comments.patch` | `docs:document.comment:update` |
| `file.comment.replys.create` | `docs:document.comment:create` |
| `file.comment.replys.delete` | `docs:document.comment:delete` |
| `file.comment.replys.list` | `docs:document.comment:read` |
| `file.comment.replys.update` | `docs:document.comment:update` |
| `permission.members.auth` | `docs:permission.member:auth` |
| `permission.members.create` | `docs:permission.member:create` |
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
| `permission.public.get` | `docs:permission.setting:read` |
| `permission.public.patch` | `docs:permission.setting:write_only` |
| `metas.batch_query` | `drive:drive.metadata:readonly` |
| `user.remove_subscription` | `docs:event:subscribe` |
| `user.subscription` | `docs:event:subscribe` |
| `user.subscription_status` | `docs:event:subscribe` |
| `file.statistics.get` | `drive:drive.metadata:readonly` |
| `file.view_records.list` | `drive:file:view_record:readonly` |
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |

View File

@@ -12,7 +12,7 @@
| 字段 | 含义 |
|------|------|
| `summary.downloaded` | 成功下载的文件数 |
| `summary.skipped` | `--if-exists=skip` 跳过的文件数 |
| `summary.skipped` | `--if-exists=skip` `--if-exists=smart` 命中“无需下载”而跳过的文件数 |
| `summary.failed` | 下载或写盘失败的文件数 |
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error` |
@@ -38,6 +38,10 @@
# 基础用法 —— 把云端 fldcXXX 镜像到 ./repo
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx
# 推荐的重复同步用法smart 会按 modified_time 跳过已经对齐的本地文件
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--if-exists smart
# 已存在的本地文件保持不动
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--if-exists skip
@@ -58,7 +62,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|------|------|------|------|
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
| `--folder-token` | 是 | string | 源 Drive 文件夹 token |
| `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`默认/ `skip` |
| `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`**默认**Drive 作为权威源时使用)/ `smart`**推荐用于重复增量同步**;当本地 mtime 已与远端 `modified_time` 匹配或更新时跳过下载/ `skip` |
| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `rename` / `newest` / `oldest` |
| `--delete-local` | 否 | bool | 删除本地"云端没有的常规文件"**不删空目录**,因此是 file-level mirror**必须配合 `--yes`** |
| `--yes` | 否 | bool | 确认 `--delete-local`;不传时该破坏性操作在 Validate 阶段被拒绝 |
@@ -67,7 +71,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
- **只下载 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)会被跳过 —— 它们没有等价的本地二进制可写盘,否则会变成产生噪声的"假"下载。
- 子文件夹会递归遍历rel_path 形如 `sub1/sub2/file.txt`,本地缺失的父目录会被自动创建。
- 已存在的本地文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的自己改名再 pull。
- 已存在的本地文件按 `--if-exists` 决定 `overwrite` / `smart` / `skip`。其中 **`smart` 是推荐的重复同步模式**:只要本地 mtime 在远端时间精度下已经等于或晚于远端 `modified_time`,就跳过下载;时间戳缺失/非法时会退回安全路径继续下载,不会盲跳。想做 `keep-both` 这类的仍需自己改名再 pull。
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote rename|newest|oldest` 时才会继续。
## --delete-local 的安全行为
@@ -106,8 +110,8 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
## 性能注意
- 下载流量 ≈ 云端待下载文件的总字节数。pull 是**全量**写盘 —— 跟 `+status` 不一样,不会跳过"内容相同"的文件status 是按 hash 比较pull 是按 `--if-exists`),所以一次跑可能很重
-避免重跑全量,可以先 `+status` 找出 `new_remote``modified`,再只对这些文件单独 `+download`
- 默认 `overwrite` 下,重复跑会重新下载所有命中的同名文件;`skip` 下则完全不碰已存在文件;**`smart` 下才会按 `modified_time` 跳过已经对齐的本地文件**,适合重复增量同步
-更精细地控制下载量,可以先 `+status` 找出 `new_remote``modified`,再只对这些文件单独 `+download`;或者直接在整目录同步时使用 `--if-exists smart`
- 大文件会用 SDK 的流式下载(不会把整个 body 读进内存),但本地磁盘空间需要够。
## 所需 scope

View File

@@ -12,7 +12,7 @@
| 字段 | 含义 |
|------|------|
| `summary.uploaded` | 成功新建或覆盖的文件数 |
| `summary.skipped` | `--if-exists=skip` 跳过的文件数 |
| `summary.skipped` | `--if-exists=skip` `--if-exists=smart` 命中“无需传输”而跳过的文件数 |
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error` |
@@ -36,10 +36,14 @@
## 命令
```bash
# 基础用法 —— 把本地 ./repo 增量推送到云端 fldcXXX
# 基础用法 —— 把本地 ./repo 推送到云端 fldcXXX
# 默认 --if-exists=skip已经存在的远端文件保持不动只新增、不覆盖。
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx
# 重复同步时可用 smart 做增量优化:它会按 modified_time 跳过已对齐的远端文件;但如果远端更旧,仍会继续走覆盖路径
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--if-exists smart
# 显式覆盖远端同名文件(依赖 upload_all 的灰度协议字段,详见下文"覆盖语义"
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--if-exists overwrite
@@ -62,7 +66,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|------|------|------|------|
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
| `--folder-token` | 是 | string | 目标 Drive 文件夹 token |
| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`**默认**,安全)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义" |
| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`**默认**,安全)/ `smart`(用于重复增量同步;当远端 `modified_time` 已匹配或更新时跳过上传,否则继续走覆盖路径)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义" |
| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `newest` / `oldest` |
| `--delete-remote` | 否 | bool | 删除云端本地不存在的文件(文件级镜像;**不会**清理远端只有的目录);**必须配合 `--yes`**,且 Validate 阶段会动态检查 `space:document:delete` scope |
| `--yes` | 否 | bool | 确认 `--delete-remote`;不传时该破坏性操作在 Validate 阶段被拒绝 |
@@ -71,13 +75,15 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
- **只上传 / 覆盖 / 删除 Drive `type=file`**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)即使在同一 rel_path 下出现,也不会被覆盖或删除 —— 它们没有等价的本地二进制。
- **本地目录结构整体被镜像**:所有子目录(含**空目录**)会按需在 Drive 上 `create_folder`;同名远端目录复用其 token不重建。空目录不计入 `summary.uploaded`,但会在 `items[]` 里以 `folder_created` 形式留痕。
- 已存在的远端文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的自行改名再 push。
- 已存在的远端文件按 `--if-exists` 决定 `overwrite` / `smart` / `skip`。其中 `smart` 是**增量优化模式**:只要远端 `modified_time` 在同等时间精度下已经等于或晚于本地 mtime就跳过上传时间戳缺失/非法时会退回安全路径继续上传,不会盲跳。**但如果远端更旧,`smart` 会继续走和 `overwrite` 相同的覆盖路径,因此也继承同样的 rollout / version 返回 caveat。** 想做 `keep-both` 这类的仍需自行改名再 push。
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote newest|oldest` 时才会选择一个远端文件继续。启用 `--delete-remote` 时,未被选中的 duplicate sibling 也会被删除,最终远端只保留一个被选中的文件副本;只有在 `--if-exists=overwrite` 成功时,才能保证该副本内容与本地对齐。
## 覆盖语义
`--if-exists=overwrite``POST /open-apis/drive/v1/files/upload_all`,并在 form 中带上现有文件的 `file_token`,由后端原地更新内容并返回新版本号。`items[].version` 字段会回填该版本号。
`--if-exists=smart` 是给“重复跑同步”的场景增加的增量优化:当远端 `modified_time` 在同等时间精度下已经等于或晚于本地 mtime 时,命令会把该文件计为 `skipped`;时间戳缺失、非法或更旧时,则继续走正常上传/覆盖路径。**也就是说,只要 smart 判定“远端不够新”,它就会进入与 `--if-exists=overwrite` 相同的覆盖实现,因此在未 rollout version 字段的 tenant 上仍可能非零失败。**
> **为什么默认是 `skip` 而不是 `overwrite`** `upload_all` 接受 `file_token` 字段、并在响应里返回 `version` 是设计文档Drive 同步盘)规定的协议;此后端尚在灰度发布。在还未开通该字段的 tenant 上,`--if-exists=overwrite` 会因"无 version 返回"而把对应文件标成 `failed`,整次 `+push` 也会因此非零退出。所以默认值故意定为 `skip`:第一次往一个已经有内容的目录里 push不会因为协议没到位就把整次运行打挂要真的覆盖远端必须显式带 `--if-exists overwrite`。新建上传不依赖该字段,不受影响。
大文件(>20MB会自动切到三段式 `upload_prepare` / `upload_part` / `upload_finish`;该路径下 `version` 暂未在响应中返回,覆盖结果中 `items[].version` 会留空,但 `file_token``action: overwritten` 仍会正确产出。
@@ -122,8 +128,8 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
## 性能注意
- 上传流量 ≈ 本地待上传文件的总字节数。push 是**全量**上传 —— 跟 `+status` 不一样,不会按 hash 跳过"内容相同"的文件status 是按 hash 比较push 是按 `--if-exists`),所以一次跑可能很重
-避免重跑全量,可以先 `+status` 找出 `new_local``modified`,再只对这些文件单独上传 / 覆盖。
- 默认 `skip` 下,已存在的远端文件一律不碰;`overwrite` 下,重复跑会重传所有命中的同名文件;`smart` 下会按 `modified_time` 跳过已对齐的远端文件,但对“远端更旧”的文件仍会进入覆盖路径,因此它减少的是**不必要的重传**,不是把覆盖风险完全拿掉
-更精细地控制传输量,可以先 `+status` 找出 `new_local``modified`,再只对这些文件单独上传 / 覆盖;或者直接在整目录同步时使用 `--if-exists smart`
- 大文件会用三段式分片上传(不会把整个 body 读进内存),但本地磁盘和上行带宽需要够。
## 所需 scope

View File

@@ -3,16 +3,19 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
按 SHA-256 内容哈希比较本地目录与飞书云空间文件夹,输出四类差异:
**精确 SHA-256**(默认)或 **快速 modified_time**`--quick`比较本地目录与飞书云空间文件夹,输出四类差异:
| 字段 | 含义 |
|------|------|
| `new_local` | 仅本地存在 |
| `new_remote` | 仅云端存在 |
| `modified` | 双端都存在但 hash 不一致 |
| `unchanged` | 双端都存在且 hash 一致 |
| `modified` | 双端都存在且本次检测判定为已变更:`detection=exact` 时表示 hash 不一致;`detection=quick` 时表示本地 mtime 与远端 `modified_time` 不一致,或远端时间戳不可可信 |
| `unchanged` | 双端都存在且本次检测判定为未变更:`detection=exact` 时表示 hash 一致;`detection=quick` 时表示本地 mtime 与远端 `modified_time` 相等 |
只读命令:流式 hash不下载落盘但双端都有的文件会从云端拉一份字节流过来在内存里算 hash大目录 / 大文件会有可观的网络流量。
只读命令:
- 默认 `detection=exact`:双端都有的文件会从云端拉一份字节流过来在内存里算 hash不下载落盘但大目录 / 大文件会有可观的网络流量。
-`--quick``detection=quick`:只比较本地 mtime 与远端 `modified_time`**不下载远端文件内容**,适合先做快速预检查;它是 best-effort不等同于严格内容一致性判断。
## 远端同名文件冲突
@@ -26,7 +29,13 @@ lark-cli drive +status \
--local-dir ./repo \
--folder-token fldcnxxxxxxxxx
# 只看 hash 不一致的项(结合 --jq 过滤)
# 快速模式 —— 只比较 modified_time不下载远端文件内容
lark-cli drive +status \
--local-dir ./repo \
--folder-token fldcnxxxxxxxxx \
--quick
# 只看判定为 modified 的项exact=hash 不一致quick=mtime 不一致)(结合 --jq 过滤)
lark-cli drive +status \
--local-dir ./repo \
--folder-token fldcnxxxxxxxxx \
@@ -39,6 +48,7 @@ lark-cli drive +status \
|------|------|------|------|
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃逸到 cwd 外的相对路径会被 CLI 直接拒绝) |
| `--folder-token` | 是 | string | Drive 文件夹 token |
| `--quick` | 否 | bool | 快速模式:只比较本地 mtime 与远端 `modified_time`,跳过远端下载和 SHA-256 计算;输出里的 `detection` 会变成 `quick` |
## 输出 schema
@@ -46,6 +56,7 @@ lark-cli drive +status \
```json
{
"detection": "exact",
"new_local": [{"rel_path": "..."}],
"new_remote": [{"rel_path": "...", "file_token": "..."}],
"modified": [{"rel_path": "...", "file_token": "..."}],
@@ -53,6 +64,11 @@ lark-cli drive +status \
}
```
其中:
- `detection=exact`:默认模式,双端都有的文件会下载远端字节流并做 SHA-256 比较。
- `detection=quick``--quick` 模式,只按本地 mtime 与远端 `modified_time` 做 best-effort 判断。
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir``--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
远端同名文件冲突时:
@@ -84,6 +100,7 @@ lark-cli drive +status \
- 子文件夹会递归遍历rel_path 形如 `sub1/sub2/file.txt`
- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。
- 本地侧只比对常规文件regular file符号链接、设备文件等被忽略。
- `--quick` 模式下,双端都有的文件只在 **远端时间精度** 下比较 `modified_time` / 本地 mtime相等才记为 `unchanged`,否则记为 `modified`;远端时间戳缺失或非法时,走保守路径记为 `modified`,不会盲判 `unchanged`
## 范围限制
@@ -99,9 +116,10 @@ lark-cli drive +status \
## 性能注意
- `unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
- 默认 `detection=exact` 下,`unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
- `--quick` / `detection=quick` 下,不会下载双端共有文件的远端内容,执行时间更接近 `O(文件数量)`,而不是 `O(总文件大小)`
- 仅一侧存在的文件不会被下载。
- Hash 计算在内存里流式做io.Copy → sha256.New不会把云端文件落到磁盘。
- 默认模式的 hash 计算在内存里流式做io.Copy → sha256.New不会把云端文件落到磁盘。
## 所需 scope
@@ -110,7 +128,7 @@ lark-cli drive +status \
| 列出文件夹 / 子目录 | `drive:drive.metadata:readonly` |
| 下载并 hash 文件 | `drive:file:download` |
如果当前 token 缺这些 scope命令会直接`missing_scope` 并提示重新登录。`drive:drive` 在部分企业被策略禁用,所以 +status 故意只声明上面这两个细粒度 scope。
默认会先要求 `drive:drive.metadata:readonly`。在 `detection=exact` 路径(默认,不传 `--quick`CLI 还会额外要求 `drive:file:download`;传 `--quick` 时不会要求下载 scope。如果当前 token 缺本次执行路径需要的 scope命令会报 `missing_scope` 并提示重新登录。`drive:drive` 在部分企业被策略禁用,所以 +status 故意只依赖上面这细粒度 scope。
## 参考

View File

@@ -23,12 +23,16 @@ lark-cli drive +upload --file ./report.pdf
# 自定义上传后的文件名
lark-cli drive +upload --file ./report.pdf --name "季度总结.pdf"
# 覆盖已存在文件(原地覆盖,保留 file_token
lark-cli drive +upload --file ./report.pdf --file-token boxcn_existing_file
# 原生命令(高级/分片上传):预上传 + 完成上传
lark-cli drive files upload_prepare --data '{
"file_name": "report.pdf",
"parent_type": "explorer",
"parent_node": "fldbc_xxx",
"size": 1048576
"size": 1048576,
"file_token": "boxcn_existing_file"
}'
lark-cli drive files upload_finish --data '{
"upload_id": "<UPLOAD_ID>",
@@ -40,7 +44,9 @@ lark-cli schema drive.files.upload_prepare
```
> [!IMPORTANT]
> 如果文件是**以应用身份bot上传**的,如 `lark-cli drive +upload --as bot` 在上传成功后CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。
> 如果文件是**以应用身份bot新建上传**的,如 `lark-cli drive +upload --as bot` 在上传成功后CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。
>
> 如果这次调用传了 `--file-token`,表示是在**覆盖已有文件**CLI **不会**额外修改该文件权限。
>
> 以应用身份上传时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
> - `status = granted`:当前 CLI 用户已获得该文件的可管理权限
@@ -51,12 +57,18 @@ lark-cli schema drive.files.upload_prepare
>
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
> [!TIP]
> 当底层上传接口返回版本号时shortcut 会在结果里额外透出 `version`。
## 目标位置选择(关键)
- 上传到 Drive 文件夹:传 `--folder-token <folder_token>`shortcut 会发送 `parent_type=explorer`
- 上传到 wiki 节点:传 `--wiki-token <wiki_token>`shortcut 会发送 `parent_type=wiki`
- 上传到 Drive 根目录:`--folder-token``--wiki-token` 都不传
- 覆盖已有文件:额外传 `--file-token <existing_file_token>`shortcut 会把它原样透传到底层 `upload_all` / `upload_prepare`,让后端按覆盖语义写入
- bot 模式下,`--file-token` 覆盖只改文件内容;不会额外给当前 CLI 用户补 `full_access`
- 不要传空目标值:`--folder-token ""` / `--wiki-token ""` 会被视为参数错误;如需上传到 Drive 根目录,应直接省略这两个参数
- 不要传空 `--file-token`:如需新建上传,直接省略该参数;显式传空字符串会报错
- `--folder-token``--wiki-token` 互斥,不要同时传
- `--wiki-token` 传的是 **wiki node token**,不是 `space_id`
@@ -65,6 +77,7 @@ Shortcut 参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file` | 是 | 本地文件路径 |
| `--file-token` | 否 | 已存在文件的 token传入后按“覆盖已有文件”语义上传 |
| `--folder-token` | 否 | 目标文件夹 token`--wiki-token` 互斥;省略时默认为 Drive 根目录;显式传空字符串会报错 |
| `--wiki-token` | 否 | 目标 wiki 节点 token`--folder-token` 互斥;会映射为 `parent_type=wiki``parent_node=<wiki_token>`;显式传空字符串会报错 |
| `--name` | 否 | 上传后的文件名;默认使用本地文件名 |
@@ -77,6 +90,7 @@ Shortcut 参数:
| `parent_type` | 是 | 父节点类型;上传到文件夹 / 根目录时用 `"explorer"`,上传到 wiki 节点时用 `"wiki"` |
| `parent_node` | 是 | 父节点 token`explorer` 时传文件夹 token根目录可为空字符串`wiki` 时传 wiki node token |
| `size` | 是 | 文件大小(字节) |
| `file_token` | 否 | 已存在文件 token传入后覆盖该文件内容 |
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。

View File

@@ -1,7 +1,7 @@
---
name: lark-im
version: 1.0.0
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、管理标记数据时使用。"
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -68,9 +68,10 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| Shortcut | 说明 |
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager |
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
| [`+chat-list`](references/lark-im-chat-list.md) | List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by `--query` keyword and/or `--member-ids`; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, and pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
@@ -96,7 +97,6 @@ lark-cli im <resource> <method> [flags] # 调用 API
- `create` — 创建群。Identity: `bot` only (`tenant_access_token`).
- `get` — 获取群信息。Identity: supports `user` and `bot`; the caller must be in the target chat to get full details, and must belong to the same tenant for internal chats.
- `link` — 获取群分享链接。Identity: supports `user` and `bot`; the caller must be in the target chat, must be an owner or admin when chat sharing is restricted to owners/admins, and must belong to the same tenant for internal chats.
- `list` — 获取用户或机器人所在的群列表。Identity: supports `user` and `bot`.
- `update` — 更新群信息。Identity: supports `user` and `bot`.
### chat.members
@@ -141,7 +141,6 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `chats.create` | `im:chat:create` |
| `chats.get` | `im:chat:read` |
| `chats.link` | `im:chat:read` |
| `chats.list` | `im:chat:read` |
| `chats.update` | `im:chat:update` |
| `chat.members.bots` | `im:chat.members:read` |
| `chat.members.create` | `im:chat.members:write_only` |

View File

@@ -2,7 +2,7 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, and chat type (private/public).
Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, chat type (private/public), and group mode. Set `--chat-mode topic` to create a topic chat.
This skill maps to the shortcut: `lark-cli im +chat-create` (internally calls `POST /open-apis/im/v1/chats`).
@@ -18,6 +18,9 @@ lark-cli im +chat-create --name "My Group"
# Create a public group (name is required and must be at least 2 characters)
lark-cli im +chat-create --name "Public Group" --type public
# Create a topic chat
lark-cli im +chat-create --name "Topic Group" --chat-mode topic
# Specify the group owner
lark-cli im +chat-create --name "My Group" --owner ou_xxx
@@ -55,12 +58,15 @@ lark-cli im +chat-create --name "My Group" --dry-run
| `--users <ids>` | No | Up to 50, format `ou_xxx` | Comma-separated user open_ids |
| `--bots <ids>` | No | Up to 5, format `cli_xxx` | Comma-separated bot app IDs |
| `--owner <open_id>` | No | Format `ou_xxx` | Owner open_id (defaults to the bot when using `--as bot`, or the authorized user when using `--as user`) |
| `--type <type>` | No | `private` (default) or `public` | Group type |
| `--type <type>` | No | `private` (default) or `public` | Group type. Default to `private`; pass `public` only when the user explicitly asks for a discoverable/public group. |
| `--chat-mode <mode>` | No | `group` (default) or `topic` | Group mode; `topic` creates a topic chat (not the same as `group_message_type=thread`). When the user asks for a topic chat, pass `topic` explicitly — do not rely on the default. |
| `--set-bot-manager` | No | - | Set the creating bot as a group manager (only effective with `--as bot`) |
| `--format json` | No | - | Output as JSON |
| `--as <identity>` | No | `bot` or `user` | Identity type |
| `--dry-run` | No | - | Preview the request without executing it |
> **`--chat-mode topic` vs "normal group with topic-message mode"**: `--chat-mode topic` here creates a 话题群 — the entire group is a topic chat. This is different from "normal group (`chat_mode=group`) + topic-message mode (`group_message_type=thread`)". This CLI exposes only `chat_mode`; `group_message_type` is intentionally not surfaced.
## AI Usage Guidance
### When using `--as bot`

View File

@@ -0,0 +1,113 @@
# im +chat-list
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
List groups the current user (or bot, with `--as bot`) is a member of. Useful for enumerating "my chats" without a search keyword, or for bulk operations against the caller's chats. Supports pagination, sort order, and (user identity only) muted-chat filtering.
This skill maps to the shortcut: `lark-cli im +chat-list` (internally calls `GET /open-apis/im/v1/chats`).
## Commands
```bash
# List the user's chats (default sort: ByCreateTimeAsc)
lark-cli im +chat-list
# Sort by recent activity (most recently active first)
lark-cli im +chat-list --sort-type ByActiveTimeDesc
# Limit page size
lark-cli im +chat-list --page-size 50
# Pagination
lark-cli im +chat-list --page-token "xxx"
# Drop muted chats (user identity only)
lark-cli im +chat-list --exclude-muted
# JSON output
lark-cli im +chat-list --format json
# Preview the request without executing it
lark-cli im +chat-list --dry-run
```
## Parameters
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
| `--sort-type <type>` | No | `ByCreateTimeAsc` (default), `ByActiveTimeDesc` | Result ordering |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request without executing it |
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
## Output Fields
| Field | Description |
|------|------|
| `chat_id` | Chat ID (`oc_xxx` format) |
| `name` | Chat name |
| `description` | Chat description |
| `owner_id` | Owner ID (type controlled by `--user-id-type`) |
| `external` | Whether the chat is external |
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
## Filtering muted chats
`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the list call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped.
When the flag is set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); `fetched_count == returned_count + filtered_count` always holds:
```json
{
"chats": [...],
"filter": {
"applied": "exclude_muted",
"fetched_count": 20,
"returned_count": 17,
"filtered_count": 3,
"hint": "Filtered out 3 muted chat(s) on this page (17 remaining); use --page-token to fetch more."
}
}
```
## Usage Scenarios
### Scenario 1: List my recent chats
```bash
lark-cli im +chat-list --sort-type ByActiveTimeDesc --page-size 10
```
### Scenario 2: List my non-muted chats sorted by activity
```bash
lark-cli im +chat-list --sort-type ByActiveTimeDesc --exclude-muted
```
### Scenario 3: Iterate all my chats programmatically
```bash
TOKEN=""
while :; do
RESP=$(lark-cli im +chat-list --page-size 100 --page-token "$TOKEN" --format json)
echo "$RESP" | jq -r '.data.chats[].chat_id'
HAS_MORE=$(echo "$RESP" | jq -r '.data.has_more')
[ "$HAS_MORE" = "true" ] || break
TOKEN=$(echo "$RESP" | jq -r '.data.page_token')
done
```
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
| `--exclude-muted` returns all chats unfiltered and `hint` says "no effect under bot identity" | Running under `--as bot` (mute API is UAT-only) | Switch to `--as user` for mute filtering |

View File

@@ -129,7 +129,7 @@ lark-cli api GET /open-apis/im/v1/messages \
lark-cli im +chat-search --query "<chat name keyword>" --format json
lark-cli im +chat-messages-list --chat-id <chat_id>
```
**Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.**
**Do not use `im chats search` or `+chat-list` — always use the `+chat-search` shortcut.**
2. **Prefer `--chat-id` when available:** if the chat_id is already known, use it directly to avoid extra API calls.
3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually. This requires user identity (`--as user`); with bot identity, resolve the p2p `chat_id` yourself and pass it via `--chat-id`.
4. **For time ranges:** both ISO 8601 and date-only inputs are supported. Date-only is usually simpler.

View File

@@ -49,6 +49,7 @@ lark-cli im +chat-search --query "project" --dry-run
| `--sort-by <field>` | No | `create_time_desc`, `update_time_desc`, `member_count_desc` | Sort field in descending order |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request without executing it |
@@ -65,6 +66,27 @@ lark-cli im +chat-search --query "project" --dry-run
| `external` | Whether the chat is external |
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
## Filtering muted chats
`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the search call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped.
When the flag is set, the JSON envelope gains a `filter` sub-object (absent otherwise, so existing consumers are unaffected); `fetched_count == returned_count + filtered_count` always holds:
```json
{
"chats": [...],
"filter": {
"applied": "exclude_muted",
"fetched_count": 20,
"returned_count": 19,
"filtered_count": 1,
"hint": "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more."
}
}
```
Note: only confirmed-muted chats count toward `filtered_count`; non-member public groups are retained and surfaced in `hint`. For strict member-only results, combine with `--search-types "private,public_joined,external"`.
## Usage Scenarios
### Scenario 1: Search chats that contain a keyword
@@ -106,7 +128,7 @@ When the user asks to search chats, follow these rules:
2. **Search scope is limited:** only chats visible to the current user or bot can be found (joined chats plus public chats). This is not a global search over all chats.
3. **Control result volume:** the result set may be large. Use `--page-size` deliberately.
4. **Suggest follow-up actions:** after finding a chat, common next steps include listing recent messages (`im +chat-messages-list`) or sending a message (`im +messages-send`).
5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `im chats list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity.
5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `+chat-list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity.
## References

View File

@@ -168,7 +168,7 @@ lark-cli im +chat-search --query "<chat name keyword>" --format json
lark-cli im +messages-search --query "keyword" --chat-id <chat_id>
```
**Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.**
**Do not use `im chats search` or `+chat-list` — always use the `+chat-search` shortcut.**
## Work Summary / Report Generation

View File

@@ -174,7 +174,7 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PA
lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN
```
## 常见错误与排查

View File

@@ -1,7 +1,7 @@
---
name: lark-shared
version: 1.0.0
description: "飞书/Lark CLI 共享基础应用配置初始化、认证登录auth login、身份切换--as user/bot、权限与 scope 管理、Permission denied 错误处理、安全规则。当用户需要第一次配置(`lark-cli config init`)、使用登录授权(`lark-cli auth login`)、遇到权限不足、切换 user/bot 身份、配置 scope、或首次使用 lark-cli 时触发。"
description: "Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output."
---
# lark-cli 共享规则
@@ -12,7 +12,9 @@ description: "飞书/Lark CLI 共享基础:应用配置初始化、认证登
首次使用需运行 `lark-cli config init` 完成应用配置。
当你帮用户初始化配置时使用background方式使用下面的命令发起配置应用流程启动后读取输出从中提取授权链接并发给用户
当你帮用户初始化配置时使用background方式使用下面的命令发起配置应用流程启动后读取输出从中提取授权链接并发给用户
**URL 转发规则**:当命令输出 `verification_url``verification_uri_complete``console_url` 等 URL 字段时,必须将 URL exactly as returned by the CLI 转发给用户,并把它视为不可修改的 opaque string不要做 URL encode/decode不要补 `%20`、空格或标点,不要重新拼接 query不要改写成 Markdown link text建议用只包含原始 URL 的代码块单独输出。
```bash
# 发起配置(该命令会阻塞直到用户打开链接并完成操作或过期)
@@ -51,7 +53,7 @@ lark-cli config init --new
#### Bot 身份(`--as bot`
将错误中的 `console_url` 提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`
将错误中的 `console_url` 原样提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`
#### User 身份(`--as user`
@@ -64,7 +66,7 @@ lark-cli auth login --scope "<missing_scope>" # 按具体 scope 授权(推
#### Agent 代理发起认证(推荐)
当你作为 AI agent 需要帮用户完成认证时使用background方式 执行以下命令发起授权流程, 并将授权链接发给用户:
当你作为 AI agent 需要帮用户完成认证时使用background方式 执行以下命令发起授权流程, 并将授权链接原样发给用户:
```bash
# 发起授权(阻塞直到用户授权完成或过期)
@@ -80,11 +82,13 @@ lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**
1. 告知用户当前版本和最新版本号
2. 提议执行更新CLI 和 Skills 需要同时更新
2. 提议执行更新(同时更新 CLI 和 Skills
```bash
npm update -g @larksuite/cli && npx skills add larksuite/cli -g -y
lark-cli update
```
3. 更新完成后提醒用户:**退出并重新打开 AI Agent**以加载最新 Skills
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。

View File

@@ -60,7 +60,7 @@ lark-cli schema drive.metas.batch_query
# 批量获取文档基本信息: 一次最多查询 10 个文档
lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx", "doc_token": "<doc_token>"}], "with_url": true}'
```
3. 需要获取文档内容时,使用 `lark-cli docs +fetch`
3. 需要获取文档内容时,使用 `lark-cli docs +fetch --api-version v2`
```bash
# 获取文档内容
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
@@ -79,7 +79,7 @@ lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participant
| 用户意图 | 推荐命令 | 所在 skill |
|---------|---------|--------|
| 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill |
| 已结束会议的发言内容 | `vc +notes``verbatim_doc_token``docs +fetch` | 本 skill |
| 已结束会议的发言内容 | `vc +notes``verbatim_doc_token``docs +fetch --api-version v2` | 本 skill |
| **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
| **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |

View File

@@ -54,7 +54,7 @@ metadata:
| 用户给了什么 | 怎么获取 |
|---|---|
| 直接给了 whiteboard token`wbcnXXX`| 直接使用 |
| 文档 URL 或 doc_id文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
| 文档 URL 或 doc_id文档中已有画板 | `lark-cli docs +fetch --api-version v2 --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
| 文档 URL 或 doc_id需要新建画板 | `lark-cli docs +update --api-version v2 --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.new_blocks[0].block_token` 取得(`block_type == "whiteboard"` 的那条;参数详见 lark-doc SKILL.md|
**Step 2渲染 & 写入**

View File

@@ -57,6 +57,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |
| [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution |
| [`+delete-space`](references/lark-wiki-delete-space.md) | Delete a wiki space, polling the async delete task when needed |
| [`+space-list`](references/lark-wiki-space-list.md) | List all wiki spaces accessible to the caller |
| [`+node-list`](references/lark-wiki-node-list.md) | List wiki nodes in a space or under a parent node (supports pagination) |
| [`+node-copy`](references/lark-wiki-node-copy.md) | Copy a wiki node to a target space or parent node |
## API Resources
@@ -98,6 +101,7 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
| `members.delete` | `wiki:member:update` |
| `members.list` | `wiki:member:retrieve` |
| `nodes.copy` | `wiki:node:copy` |
| `nodes.move` | `wiki:node:move` |
| `nodes.create` | `wiki:node:create` |
| `nodes.list` | `wiki:node:retrieve` |

View File

@@ -0,0 +1,72 @@
# lark-wiki +node-copy
Copy a wiki node (including its content) to a target space or under a target parent node. Used for cross-space migration.
> ⚠️ **High-risk write** — the upstream API is flagged `danger: true`, so this shortcut requires explicit `--yes` confirmation before issuing the request. Forgetting `--yes` returns a `confirmation_required` error and the copy is **not** performed.
## Usage
```bash
lark-cli wiki +node-copy \
--space-id <source_space_id> \
--node-token <source_node_token> \
(--target-space-id <target_space_id> | --target-parent-node-token <token>) \
[--title <new_title>] \
--yes \
[--as user|bot]
```
## Flags
| Flag | Required | Description |
|------|----------|-------------|
| `--space-id` | **Yes** | Source wiki space ID |
| `--node-token` | **Yes** | Source node token to copy |
| `--target-space-id` | Conditional | Target space ID. Required if `--target-parent-node-token` is not set |
| `--target-parent-node-token` | Conditional | Target parent node token. Required if `--target-space-id` is not set |
| `--title` | No | New title for the copied node. Omit to keep the original title |
| `--yes` | **Yes** | Confirm the high-risk operation. Without this flag the shortcut refuses to send the API request |
| `--format` | No | Output format: `json` (default) / `pretty` / `table` / `csv` / `ndjson` |
| `--as` | No | Identity: `user` or `bot` (default: `user`) |
> At least one of `--target-space-id` or `--target-parent-node-token` must be provided.
## Output
```json
{
"space_id": "target_space_id",
"node_token": "wikcn_EXAMPLE_TOKEN",
"obj_token": "doccn_EXAMPLE_TOKEN",
"obj_type": "docx",
"node_type": "origin",
"title": "Getting Started (Copy)",
"parent_node_token": "",
"has_child": false
}
```
## Migration workflow
To migrate a subtree from one space to another:
```bash
# 1. List nodes in the source space
lark-cli wiki +node-list --space-id source_space_id
# 2. Copy each node to the target space
lark-cli wiki +node-copy \
--space-id <source_space_id> \
--node-token wikcn_EXAMPLE_TOKEN \
--target-space-id <target_space_id> \
--yes
```
## Notes
- Copying is recursive — the subtree under the node is also copied.
- There is no native move API; migration = copy to target + (manually delete source if needed).
## Required Scope
`wiki:node:copy`

View File

@@ -0,0 +1,88 @@
# lark-wiki +node-list
List wiki nodes in a space or under a specific parent node. **Default fetches a single page** (large knowledge bases can have thousands of nodes — opt into `--page-all` explicitly with an eye on `--page-limit`).
## Usage
```bash
# Default: single page of root nodes
lark-cli wiki +node-list --space-id <SPACE_ID>
# Drill into a sub-directory (still single page by default)
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token <NODE_TOKEN>
# Personal document library (user identity only)
lark-cli wiki +node-list --space-id my_library --as user
# Walk every page (capped by --page-limit, default 10)
lark-cli wiki +node-list --space-id <SPACE_ID> --page-all
# Walk every page with a higher cap
lark-cli wiki +node-list --space-id <SPACE_ID> --page-all --page-limit 30
# Resume from a cursor
lark-cli wiki +node-list --space-id <SPACE_ID> --page-token <TOKEN>
# Pretty / table output
lark-cli wiki +node-list --space-id <SPACE_ID> --format pretty
```
## Flags
| Flag | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `--space-id` | string | **Yes** | — | Wiki space ID. Use `my_library` for personal document library (user only) |
| `--parent-node-token` | string | No | — | Parent node token; omit to list the space root |
| `--page-size` | int | No | 50 | Page size, 1-50 |
| `--page-token` | string | No | — | Page cursor; implies single-page fetch (no auto-pagination) |
| `--page-all` | bool | No | `false` | Automatically paginate through all pages (capped by `--page-limit`) |
| `--page-limit` | int | No | 10 | Max pages with `--page-all` (0 = unlimited) |
| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
| `--as` | enum | No | `user` | Identity: `user` or `bot` |
## Output
```json
{
"ok": true,
"data": {
"nodes": [
{
"space_id": "6946843325487912356",
"node_token": "wikcn_EXAMPLE_TOKEN",
"obj_token": "doccn_EXAMPLE_TOKEN",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Getting Started",
"has_child": true
}
],
"has_more": false,
"page_token": ""
},
"meta": { "count": 1 }
}
```
When the default single-page fetch (or `--page-all` capped by `--page-limit`) does not exhaust the upstream cursor, `has_more=true` and `page_token=<cursor>` so the caller can resume via `--page-token` or by increasing `--page-limit`.
## Traverse the wiki tree
To list all content recursively, call `+node-list` again with each node's `node_token` as `--parent-node-token` when `has_child` is `true`.
```bash
# Step 1: list root nodes
lark-cli wiki +node-list --space-id 6946843325487912356
# Step 2: drill into a node that has children
lark-cli wiki +node-list --space-id 6946843325487912356 --parent-node-token wikcn_EXAMPLE_TOKEN
```
## Notes
- `--space-id my_library` is a per-user alias and only valid with `--as user`. The shortcut will refuse `--as bot` with `my_library` upfront.
## Required Scope
`wiki:node:retrieve`

Some files were not shown because too many files have changed in this diff Show More