Compare commits

..

12 Commits

Author SHA1 Message Date
liangshuo-1
686c91dc71 chore(release): v1.0.23 (#737)
Change-Id: I48f780acac9731585aeec0a51f5b403a00804dbc
2026-04-30 18:04:10 +08:00
河伯
cfd89e0e28 feat(doc): warn when callout uses type= without background-color (#467)
* feat(doc): expand callout type= shorthand into background-color and border-color

When users write <callout type="warning" emoji="📝"> without an explicit
background-color, the Feishu doc renders the block with no color. This
commit adds fixCalloutType() which maps the semantic type= attribute to
the corresponding background-color/border-color pair accepted by create-doc.

- warning → light-yellow/yellow
- info/note → light-blue/blue
- tip/success/check → light-green/green
- error/danger → light-red/red
- caution → light-orange/orange
- important → light-purple/purple

Explicit background-color or border-color attributes are always preserved.
The fix is applied via prepareMarkdownForCreate() in both +create and
+update paths, and also inside fixExportedMarkdown() for round-trip fidelity.

* refactor(doc): replace silent callout type→color injection with hint output

Per reviewer feedback (SunPeiYang996), silently rewriting user Markdown is
the wrong layer for this adaptation. The type→color mapping is not part of
the Feishu spec, and covert transforms make debugging harder.

Replace fixCalloutType() (which rewrote the Markdown) with WarnCalloutType()
which leaves the Markdown unchanged and instead writes a hint line to stderr
for each callout tag that has type= but no background-color, telling the user
the recommended explicit attributes to add:

  hint: callout type="warning" has no background-color; consider: background-color="light-yellow" border-color="yellow"

Also fixes CodeRabbit feedback: the type= regex now accepts both single-quoted
and double-quoted attribute values (type='warning' and type="warning").

* fix(doc): harden background-color detection in WarnCalloutType

CodeRabbit flagged that the previous strings.Contains(attrs,
"background-color=") check missed forms like 'background-color =
"light-red"' with whitespace around the equals sign. Replace with a
regex that tolerates optional whitespace, and add a regression test.

* fix(doc): close real review gaps left over after rebase

PR #467's review thread had three substantive comments
(`fangshuyu-768`, 2026-04-21) that the prior reply messages claimed
were fixed in commit 7d4b556 — but that commit no longer exists on the
branch (lost in a rebase / squash), and the head still ships the
original buggy code. This commit makes the fixes real.

Three behavior fixes in shortcuts/doc/markdown_fix.go:

1. (#5) Tighten the type= and background-color= regex anchors. \b sits
   at any word/non-word boundary, and `-` is a non-word char, so
   `\btype=` also matched the suffix of `data-type=` — a tag like
   `<callout data-type="warning">` would emit a bogus light-yellow
   hint. Switched both regexes to `(?:^|\s)…` so a real attribute
   separator is required. The same anchor on background-color closes
   the symmetric case where a `data-background-color=` attribute
   would silently suppress the real hint.

2. (#4) WarnCalloutType is now a fence-aware line walker. Previously
   the regex ran over the entire markdown body, so a callout sample
   inside a documentation code fence (```markdown … ```) would
   generate a phantom stderr hint every time the docs mentioned the
   feature. The walker tracks fence state via the existing
   codeFenceOpenMarker / isCodeFenceClose helpers from
   docs_update_check.go, which handle both backtick and tilde fences
   per CommonMark §4.5.

3. (#3) Drop the ReplaceAllStringFunc-as-iterator pattern. The
   previous code routed callout iteration through a rewrite primitive
   whose rebuilt-string return value was discarded, then ran the same
   regex a second time inside the callback to recover the capture
   groups. New scanCalloutTagsForWarning helper uses
   FindAllStringSubmatch — one pass, no thrown-away allocation,
   intent matches the surface (read-only scan, not a mutator).

Tests: 5 new TestWarnCalloutType subtests pin each contract:

- data-type attribute does not trigger hint (#5)
- data-background-color does not suppress hint (#5, symmetric)
- callout inside backtick fence emits no hint (#4)
- callout inside tilde fence emits no hint (#4)
- callout after fence close still emits hint (#4, fence-state reset)

All 14 TestWarnCalloutType cases pass; go vet / golangci-lint
--new-from-rev=origin/main both clean.
2026-04-30 17:51:08 +08:00
zhouyue-bytedance
ac4c34f2ad feat: support file-name for drive export (#685)
* feat: support file-name for drive export

* test: cover drive export file-name metadata
2026-04-30 17:30:23 +08:00
zgz2048
3ed691b25c feat(base): add markdown output for record reads (#726)
* feat(base): add record read SOP guidance

1. Add a unified lark-base record read SOP for get/search/list routing, field projection, temporary view querying, pagination, matrix result binding, and link field reads.
2. Inline command-focused parameter guidance into +record-get, +record-search, and +record-list help, including examples, JSON shape, view scope, projection, and limit constraints.
3. Preserve base shortcut flag order in help output and add tests covering record read help guidance.
4. Remove the single-method record read skill references in favor of the unified SOP.

* test(base): remove stale record list fixture

* fix(base): scan record markdown output

* fix(base): fallback record markdown output

* fix(base): unify base token wording in shortcuts and skills
2026-04-30 17:09:17 +08:00
fangshuyu-768
30ad38d4b6 feat(drive): add +pull shortcut for one-way Drive → local mirror (#696)
* feat(drive): add +pull shortcut to mirror a Drive folder onto local

Adds `drive +pull`, a one-way Drive → local mirror command. It
recursively lists --folder-token, downloads each type=file entry
into --local-dir at the matching relative path, and optionally
deletes local files absent from the remote (mirror semantics).

Implementation notes:

- Listing recurses through subfolders with the standard 200-page
  pagination loop. Online docs (docx, sheet, bitable, mindnote,
  slides) and shortcuts are skipped since there is no equivalent
  local binary to write back. Folder tree is reproduced under
  --local-dir, with parent directories auto-created by FileIO.Save.

- Per-file --if-exists=overwrite (default) | skip controls how
  pre-existing local files are treated; the framework's enum guard
  rejects any other value.

- --delete-local is the only destructive flag and is bound to --yes
  in Validate: --delete-local without --yes is rejected upfront so
  no listing or download even runs. --delete-local --yes performs
  downloads first, then walks --local-dir and removes regular files
  not present in the remote map. This matches the spec doc's
  "high-risk-write" intent for --delete-local without making the
  default pull path require confirmation.

- --local-dir is funneled through validate.SafeLocalFlagPath so
  errors reference --local-dir instead of the framework default
  --file. FileIO().Stat then enforces existence and IsDir.

- Scopes: drive:drive.metadata:readonly + drive:file:download. The
  broader drive:drive is disabled by enterprise policy in some
  tenants.

- Listing helper (drivePullListRemote) is duplicated locally rather
  than reused from drive_status.go because that change is still in
  open PR #692; once it merges, both can be lifted into a shared
  drive package helper. TODO marker is left in the code.

Tests cover six unit scenarios (happy-path with nested subfolder +
docx skipping, --if-exists=skip, --delete-local rejection without
--yes, --delete-local --yes deletes orphans, absolute-path
rejection, bad enum) and four E2E dry-run scenarios (request shape,
absolute path rejection, --delete-local --yes guard, missing
required flag).

* docs(skills): document drive +pull in lark-drive skill

Adds references/lark-drive-pull.md covering parameters, output schema
(summary + per-item action breakdown), the type=file scoping rule,
the --if-exists policy matrix, and the --delete-local + --yes safety
contract. Calls out the network-traffic caveat (pull is full-download,
unlike +status which only fetches when both sides have the file) and
the cwd boundary on --local-dir.

Wires +pull into the Shortcuts table in SKILL.md.

* fix(drive): walk +pull on canonical absolute root to close symlink/.. escape

Same root cause as the +status fix: --local-dir was validated through
SafeLocalFlagPath but the walk used the user-supplied raw string.
SafeLocalFlagPath returns the original value (the canonical form is
discarded), and SafeInputPath itself relies on filepath.Clean for
normalization, which shrinks "link/.." to "." purely as string
manipulation. The kernel then resolves "link/.." through the symlink
target's parent at walk time, putting the traversal outside cwd.

For +pull the bug is more dangerous than for +status because it
travels through --delete-local --yes — a raw walk would let the
delete pass land on files outside cwd.

Fix:
- In Execute, resolve --local-dir via validate.SafeInputPath to get a
  canonical absolute path, and resolve "." the same way for cwd.
- Convert the resolved root back to a cwd-relative form
  (filepath.Rel) for download targets so FileIO.Save's existing
  SafeOutputPath check (which rejects absolute paths) still applies.
- For --delete-local, walk the canonical absolute root, then delete
  via the absolute path. Both values come from the validated
  safeRoot, so kernel path resolution cannot redirect a delete to a
  file outside the canonical subtree.
- drivePullWalkLocal now returns absolute paths instead of rel paths;
  the caller computes the rel_path via filepath.Rel against safeRoot
  for output / remote-set membership checks.

Adds TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef as a
regression: it stages an "escape" sibling directory containing a
sentinel file, adds a "link" symlink in cwd pointing into it, and
runs +pull --delete-local --yes against an empty remote with
--local-dir "link/..". The sentinel must survive (proving --delete
did not escape) and the in-cwd file must be removed (proving the
walk did run).

* test(drive): pin walker / download behavior on +pull symlink corner cases

Adds three regressions on top of the canonical-root walk fix:

- TestDrivePullSkipsSymlinkInsideRoot: a child symlink inside the
  validated root pointing to a sibling temp dir. Under
  --delete-local --yes with an empty remote, the sentinel inside the
  target must survive (walker did not follow the child symlink) and
  the in-cwd file must be deleted (walker did run).

- TestDrivePullSurvivesCircularSymlinkInsideRoot: a child symlink
  pointing at one of its ancestors. The walk must terminate so the
  test does not hang on the per-test timeout.

- TestDrivePullDownloadDoesNotEscapeViaSymlinkParentRef: pins the
  download half of the fix. With --local-dir "link/.." the canonical
  root resolves to cwd, so the remote file must land in cwd, not
  inside the symlink target's parent. The preexisting sentinel inside
  the escape directory must remain untouched.

* fix(drive): +pull --delete-local must not unlink local files shadowed by online docs

CodeRabbit (PR #696) flagged that the --delete-local pass treated any
local path missing from `remoteFiles` as orphaned, but `remoteFiles` only
records type=file entries. If Drive held a docx/sheet/shortcut at the
same rel_path as a local file, the local file would be unlinked even
though Drive still owned that path.

drivePullListRemote now returns two views:

  - files:    rel_path -> file_token, type=file only (download/skip set)
  - allPaths: every entry's rel_path regardless of type

The download loop continues to consume `files`; the --delete-local pass
consults `allPaths`, so an online-doc shadow of a local filename keeps
the local file safe.

Also routes the local walk and the delete through the vfs abstraction
(vfs.ReadDir + vfs.Remove) instead of filepath.WalkDir + os.Remove.
This drops the //nolint:forbidigo justifications and lines up with how
internal/keychain and internal/registry already do filesystem I/O. The
recursive vfs.ReadDir walker preserves the same "do not follow child
symlinks" semantics that filepath.WalkDir gave us, so the canonical-root
escape protections in 240b772 stay intact.

Adds TestDrivePullDeleteLocalPreservesLocalFileShadowedByOnlineDoc as a
direct regression: Drive serves keep.txt (file) plus notes.docx (docx),
local has both keep.txt and a hand-edited notes.docx; --delete-local
--yes must download keep.txt, leave notes.docx untouched, and report
deleted_local=0.

* fix(drive): count +pull delete failures in summary.failed

CodeRabbit (PR #696) flagged that both delete_failed branches in the
--delete-local pass appended an item but left the `failed` counter at
zero, so the JSON summary could legitimately report `"failed": 0` after
a partially-failed mirror. Increment failed in both branches (the
filepath.Rel error path and the vfs.Remove error path) so summary.failed
reflects every item flagged delete_failed in items[].

Adds TestDrivePullDeleteLocalCountsFailureInSummary, which forces
vfs.Remove to fail by chmod-ing the local dir 0o555 right before the
run and restoring 0o755 in t.Cleanup so t.TempDir teardown still works.

* fix(drive): swap +pull walk/remove back to filepath/os to satisfy depguard

The previous fix-up commits used vfs.ReadDir + vfs.Remove inside the
+pull shortcut, which depguard's "shortcuts-no-vfs" rule rejects:
shortcuts cannot import internal/vfs directly. CI lint failed on the
import line.

Restore the same pattern used in drive_status.go and the prior +pull
walker:

- filepath.WalkDir to enumerate files under the canonical absolute
  root, gated by //nolint:forbidigo with a comment explaining why.
- os.Remove for the actual delete, also gated by //nolint:forbidigo.

The canonical-root safety still holds: validate.SafeInputPath bounds
the walk root inside cwd before WalkDir runs, and WalkDir's default
"do not follow child symlinks" policy is preserved. The two earlier
fixes (drivePullListRemote returning allPaths so online-doc shadows
do not look orphaned, and incrementing failed on delete_failed) stay
in place.

`go test ./shortcuts/drive/...` and `golangci-lint run
--new-from-rev=origin/main` are both clean.

* fix(drive): record remote folder rel_path in +pull allPaths

Follow-up to 45fe4e3. The folder branch in drivePullListRemote merged
descendant rel_paths into allPaths but never recorded the folder's own
rel_path, so a local regular file with the same name as a remote
folder still looked orphaned and got unlinked under --delete-local.

Adds the missing allPaths[rel] for the folder case and a regression:
TestDrivePullDeleteLocalPreservesLocalFileShadowedByRemoteFolder
stages a Drive containing a folder named shadow alongside a
downloadable file, with the local side holding a regular file named
shadow; --delete-local --yes must download keep.txt and leave the
shadow file untouched.

* fix(drive): +pull pagination + dir/file conflict + skill doc symlink claim

Codex review on PR #696 surfaced three issues; addressed in one go:

1. drivePullListRemote only honored next_page_token. The shared
   common.PaginationMeta helper accepts both page_token and
   next_page_token; switched +pull over so a backend reply using
   page_token no longer makes the lister stop at page 1 (which would
   silently drop later remote files from both download and
   --delete-local).

2. --if-exists=skip swallowed mirror conflicts. The skip/overwrite
   branch only checked Stat success, so a local directory shadowing a
   remote regular file was reported as action=skipped. Now Stat's
   IsDir() is checked first; the conflict surfaces as action=failed
   with a message naming the directory, under both --if-exists=skip
   and --if-exists=overwrite, and increments summary.failed.

3. Skill doc told callers to soft-link the target into cwd if they
   wanted to pull from outside cwd. That is wrong: SafeInputPath
   evaluates symlinks before the cwd check, so a symlink pointing
   out-of-tree is rejected. Replaced the bogus shortcut with the
   actually viable options (switch the agent working directory,
   physically move/copy the target, or skip the comparison).

Two new regressions:

- TestDrivePullSurfacesDirectoryFileMirrorConflict — table test over
  both policies asserting failed=1, no skipped, action=failed, plus
  the 'is a directory' hint in the error message.

- TestDrivePullPaginationHandlesPageTokenField — first page returns
  page_token (not next_page_token) with has_more=true; asserts both
  pages are fetched and both files land on disk.

* fix(drive): +pull exits non-zero on item failures; gate --delete-local

Two PR-696 review fixes:

- Item-level failures (download error, dir/file conflict, delete error)
  now surface as a structured partial_failure ExitError instead of a
  success envelope with summary.failed > 0. Exit code becomes non-zero
  and error.detail still carries the {summary, items[]} payload, so
  AI / script callers can detect the failure via the exit code without
  reaching into the JSON body.

- A failed download pass now skips the --delete-local walk entirely.
  Previously +pull would continue removing local-only files even when
  the download phase had partially failed, leaving the mirror in a
  half-synced state (some Drive files missing locally AND some
  local-only files unlinked). Re-runs after fixing the download error
  recover cleanly.

Skill doc / shortcut description / flag desc updated to call the
operation a one-way file-level mirror, since --delete-local only
unlinks regular files and does not prune empty local directories left
behind by remote folder deletes (true directory-level mirroring is
explicitly out of scope).

Tests: existing dir/file-conflict and delete-failure cases now assert
the partial_failure ExitError shape; new test covers the
"download fails => --delete-local skipped" gating contract.

* refactor(drive): consolidate folder-listing helpers into listRemoteFolder

Closes the post-#692 / post-#709 TODO that lived in drive_pull.go (and
the matching note in drive_push.go): both #692 and #709 are now on main,
so the three near-identical recursive Drive folder listers can collapse
into one.

New shared helper in shortcuts/drive/list_remote.go:

  driveRemoteEntry { FileToken, Type, RelPath }
  listRemoteFolder(ctx, runtime, folderToken, relBase) -> map[rel]entry

Returns one entry per Drive item (every type), keyed by rel_path.
Subfolders are descended into and the folder's own entry is recorded so
callers can reason about "this rel_path is occupied by a folder"
without re-listing. Pagination via common.PaginationMeta is unchanged.

Each shortcut now derives its own per-shortcut view from the unified
listing:

  - drive_status.go: collapses to remoteFiles (Type=="file" -> token) for
    the content-hash diff.
  - drive_pull.go: derives remoteFiles (Type=="file") for the download
    set, plus remotePaths (every rel_path) as the --delete-local guard.
  - drive_push.go: derives remoteFiles (Type=="file") for upload /
    overwrite / orphan-delete, plus remoteFolders (Type=="folder") for
    the create_folder cache. drivePushRemoteEntry was a duplicate of
    driveRemoteEntry's first two fields and is dropped; the few call
    sites that read .FileToken keep working unchanged.

Per-shortcut copies removed:
  - drive_status.go: listRemoteForStatus, joinRelStatus,
    driveStatusListPageSize/FileType/FolderType
  - drive_pull.go: drivePullListRemote, drivePullJoinRel,
    drivePullListPageSize/FileType/FolderType
  - drive_push.go: drivePushListRemote, drivePushJoinRel,
    drivePushListPageSize/FileType/FolderType, drivePushRemoteEntry

drive_push_test.go's TestDrivePushHelpersRelPath is retargeted at the
shared joinRelDrive; the docstrings on the same-name-conflict tests
were tweaked to refer to "the remoteFiles view" instead of the
just-removed drivePushListRemote.

Net diff: +1 new file, -207 net lines across the four touched files.
All existing unit + e2e dry-run tests pass without behavioral change;
the rel_path / pagination / type-filter contracts each shortcut depends
on are preserved by construction.
2026-04-30 17:07:59 +08:00
calendar-assistant
4fab062219 docs: clarify minutes file-to-notes routing (#732)
Change-Id: If768200b329c5e255b13c1992b8c57d1fd8ec518
2026-04-30 16:59:22 +08:00
wittam-01
f27b8fdf40 feat: add markdown shortcuts and skill docs (#704)
Change-Id: Iced88525deb10b014b755ec68bd9a8ae6a935143
2026-04-30 15:47:36 +08:00
liangshuo-1
c100ca049e feat(cmdutil): support @file for params and data (#724)
* feat(cmdutil): support @file for --params/--data (issue #705)

Inline JSON values for --params/--data are mangled by Windows
PowerShell 5's CommandLineToArgvW. Stdin (-) was the only escape
hatch but supports just one flag at a time.

Extend ResolveInput to accept @<path> (read JSON from a file) and
@@... (escape for a literal @-prefixed value), mirroring the
shortcuts framework's resolveInputFlags semantics. With this, both
--params and --data can be sourced from files in the same call,
sidestepping shell quoting on every platform.

- internal/cmdutil/resolve.go: add @path / @@ handling, trim file
  content like stdin does, error on empty path or empty file
- internal/cmdutil/resolve_test.go: cover file read, whitespace
  trim, missing file, empty path, empty content, @@ escape, plus
  ParseJSONMap / ParseOptionalBody integration through @file
- cmd/api/api.go, cmd/service/service.go: update --params/--data
  help text to mention @file

Change-Id: I366aa0f5783fbec6f05403f7f542505098a98c82

* refactor(cmdutil): route @file through fileio.FileIO abstraction

The first cut of @file support called os.ReadFile directly inside
ResolveInput, bypassing the codebase's fileio.FileIO abstraction
(SafeInputPath validation, pluggable provider). That diverged from
how every other file-reading path works: BuildFormdata for --file
uploads and the shortcuts framework's resolveInputFlags both go
through fileio.FileIO.Open with explicit fileio.ErrPathValidation
handling.

Re-route @file through the same path:

- ResolveInput, ParseJSONMap, ParseOptionalBody now take a
  fileio.FileIO; @path uses fileIO.Open which goes through
  SafeInputPath (control-char rejection, abs-path rejection,
  symlink-escape check) — same security posture as --file
- cmd/api and cmd/service callsites pass
  Factory.ResolveFileIO(ctx); the upload path now reuses the
  resolved fileIO instead of resolving twice
- Path-validation errors surface as
  `--params: invalid file path "...": ...` distinct from
  `--params: cannot read file "...": ...` for genuine I/O errors
- Nil fileIO with an @path returns a clear
  "file input (@path) is not available" error
- Tests use localfileio.LocalFileIO with TestChdir(t, dir),
  matching the existing fileupload_test.go pattern; absolute-path
  rejection and nil-fileIO are covered

This makes the feature behave identically under any FileIO
provider (including server mode) instead of being silently bound
to the local filesystem.

Change-Id: I878c4e8fb03f43f1f19afad75ec3af9cdab7a7f9

* refactor(cmdutil): share at-file input handling

Change-Id: I92a6eb6ea8fd02054bf8f4925cd81807449d5e51
2026-04-30 15:34:45 +08:00
fangshuyu-768
4d68e09537 feat(drive): add +push shortcut for one-way local → Drive mirror (#709)
* feat(drive): add +push shortcut for one-way local → Drive mirror

Mirrors a local directory onto a Drive folder: walks --local-dir,
recursively lists --folder-token, mirrors local subdirectory structure
(including empty dirs) onto Drive via create_folder, and for each
rel_path uploads new files, overwrites already-present files, or skips
them per --if-exists. With --delete-remote --yes, any Drive type=file
entry absent locally is removed; Lark native cloud docs (docx/sheet/
bitable/mindnote/slides) and shortcuts are never overwritten or deleted.

Overwrite hits POST /open-apis/drive/v1/files/upload_all with the
existing file_token in the form body and the response's `version` is
propagated to items[].version, mirroring the markdown +overwrite
contract. Files >20MB fall back to the 3-step
upload_prepare/upload_part/upload_finish path with a single shared fd
reused via io.NewSectionReader per block.

Output is a {summary, items[]} envelope; items[].action is one of
uploaded / overwritten / skipped / folder_created / deleted_remote /
failed / delete_failed.

--delete-remote is bound to --yes upfront in Validate, same pattern as
+pull's --delete-local: a stray flag never silently deletes anything.
Path safety reuses the canonical-root walk + SafeInputPath mechanics
from the sibling +status / +pull commands.

Scopes: drive:drive.metadata:readonly + drive:file:upload +
space:folder:create. space:document:delete is intentionally NOT in the
default set — the framework's pre-flight scope check would otherwise
block plain pushes and dry-runs for callers that haven't granted delete;
--delete-remote --yes relies on the runtime DELETE call to surface
missing_scope. The skill ref calls out the scope so users running
mirror sync can grant it upfront.

13 unit tests cover the upload/overwrite/skip/delete matrix, online-doc
protection, same-name conflict between local file and native cloud doc,
empty-directory mirroring, multipart, scope/path validation, and helper
correctness. 4 dry-run e2e tests pin the request shape.

* fix(drive +push): address review — failure semantics, default skip, scope pre-check, mirror wording

- Item-level failures now bump the exit code via output.ErrBare(ExitAPI)
  while keeping the structured items[] envelope on stdout. The
  --delete-remote phase is skipped entirely when any upload / overwrite /
  folder step fails, so a partial upload never proceeds to delete remote
  orphans (a half-synced state).
- Default --if-exists flipped from "overwrite" to the safer "skip": the
  upload_all overwrite-version protocol field is still rolling out, so
  the default no longer fails a first push against a pre-populated
  folder. Callers must opt into "overwrite" explicitly.
- --delete-remote --yes now triggers a conditional space:document:delete
  scope pre-check in Validate via the new RuntimeContext.EnsureScopes
  helper, so a missing grant fails the run before any upload — instead
  of after the upload phase, which would leave orphans uncleaned.
- Description, Tips and skill doc rewritten to call this a file-level
  mirror (not a directory mirror): the command does not remove
  remote-only directories or close gaps in directory structure that
  exists only on Drive.

Tests:
- new TestDrivePushDefaultsToSkipForExistingRemote pins the new default
- new TestDrivePushSkipsDeleteAfterUploadFailure pins the half-sync
  guard and the non-zero exit on item-level failure
- new TestDrivePushExitsZeroOnCleanRun pins the inverse
- existing tests that relied on the old overwrite default now opt in
  explicitly with --if-exists=overwrite
- TestDrivePushOverwriteWithoutVersionFails updated to assert
  *output.ExitError with Code=ExitAPI
- new TestDrive_PushDryRunAcceptsDeleteRemoteWithYes (e2e) symmetric to
  the existing reject-without-yes test, pinning that EnsureScopes is a
  silent no-op when the resolver has no scope metadata

* fix(drive +push): close remaining CodeRabbit comments

Three small follow-ups on the +push review thread that were still
open after the earlier failure-semantics / default-skip / scope
pre-check fix:

- drivePushUploadAll now extracts data.file_token before checking
  larkCode, and surfaces the returned token on the partial-success
  path (non-zero code + non-empty file_token). Without this, a backend
  response where bytes already landed but code != 0 would force the
  caller to fall back to entry.FileToken and silently lose the actual
  Drive token, defeating the overwrite-error token-stability handling
  in Execute.
- TestDrivePushOverwriteWithoutVersionFails switched from "tok_keep"
  to "tok_keep_new" in the upload_all stub and now asserts that the
  returned token (not entry.FileToken) lands in items[].file_token —
  pins the contract that a regression to the fallback branch would
  otherwise pass silently.
- New TestDrivePushOverwritePartialSuccessSurfacesReturnedToken pins
  the new partial-success branch end-to-end.
- drive_push_dryrun_test.go: tightened the three Validate / cobra
  rejections from `exit != 0` to exact codes — `exit == 2` for the
  two Validate-stage rejections (--local-dir absolute,
  --delete-remote without --yes), `exit == 1` for the cobra
  required-flag check (--folder-token missing). Locks in failure
  classification so a regression that misroutes the error layer
  doesn't slip through.
2026-04-30 15:00:44 +08:00
fangshuyu-768
a3bbe00ee0 feat(drive): add +status shortcut for content-hash diff (#692)
* feat(drive): add +status shortcut for content-hash diff

Adds `drive +status`, a read-only diff primitive that walks --local-dir,
recursively lists --folder-token, and reports four buckets — new_local,
new_remote, modified, unchanged — by SHA-256 content hash.

Implementation notes:

- Drive's list/metas APIs do not expose a content hash, so files
  present on both sides are downloaded via DoAPIStream and hashed in
  memory (sha256 + io.Copy, no disk write). Files only on one side are
  not fetched. The command stays Risk: "read".

- Only Drive entries with type=file participate. Online docs (docx,
  sheet, bitable, mindnote, slides) and shortcuts are skipped — there
  is no equivalent local binary to hash against.

- --local-dir is funneled through the framework's
  validate.SafeLocalFlagPath helper so that absolute paths and any ..
  that escapes cwd are rejected with --local-dir in the error message
  (rather than the internal default --file). FileIO().Stat() then
  enforces existence and the IsDir check.

- Local walk uses filepath.WalkDir behind a //nolint:forbidigo comment.
  The runtime FileIO interface has no walker today and shortcuts can't
  import internal/vfs; SafeInputPath has already bounded the walk root
  inside cwd, so the bare walk is acceptable until a runtime-level
  walker lands.

- Scopes: drive:drive.metadata:readonly (list folders) +
  drive:file:download (fetch files for hashing). The broader
  drive:drive scope is disabled by enterprise policy in some tenants;
  this narrower pair was verified end-to-end.

Tests cover the four-bucket categorization with a nested subfolder and
docx/shortcut filtering, plus validation errors for missing local-dir,
non-directory local-dir, and absolute-path local-dir.

* docs(skills): document drive +status in lark-drive skill

Adds references/lark-drive-status.md covering parameters, output
schema, the type=file scoping rule, and the network-traffic caveat
(hash is streamed in memory, but bytes still cross the wire).

Notes that --local-dir is bounded to cwd by the CLI's path validation,
and that when a user wants to compare a directory outside cwd the
agent should ask the user to relocate or to switch the agent's working
directory rather than `cd`-ing on its own.

Wires +status into the Shortcuts table in SKILL.md.

* test(drive): cover --folder-token validation and add +status dry-run E2E

Addresses two CodeRabbit review comments on PR #692:

- Adds TestDriveStatusRejectsEmptyFolderToken and
  TestDriveStatusRejectsMalformedFolderToken so the Validate-stage
  required-check and the ResourceName format guard for --folder-token
  are exercised, not just --local-dir.

- Adds tests/cli_e2e/drive/drive_status_dryrun_test.go which drives
  the real binary in dry-run mode and asserts:

  * the request shape (GET /open-apis/drive/v1/files with
    folder_token in the dry-run envelope), plus the description text,
  * --local-dir absolute paths are rejected by Validate (which still
    runs under --dry-run) with --local-dir surfaced in the message,
  * cobra's required-flag enforcement rejects a missing
    --folder-token before any custom validation.

* fix(drive): walk +status on canonical absolute root to close symlink/.. escape

Reported in PR review: --local-dir was validated through
SafeLocalFlagPath, but the actual walk used the user-supplied raw
string. SafeLocalFlagPath returns the original value (it only checks
the path through SafeInputPath and discards the canonical form), and
SafeInputPath itself relies on filepath.Clean for path normalization.
filepath.Clean shrinks "link/.." to "." purely as string manipulation,
so the validator sees a path inside cwd. The kernel, however, resolves
"link/.." through the symlink target's parent — which is outside cwd
and is what filepath.WalkDir actually traverses.

Fix: in Execute, resolve --local-dir via validate.SafeInputPath to
get the canonical absolute path (this one fully evaluates symlinks
across the entire path), and walk that path. Each absolute walk hit
is converted to a cwd-relative form via filepath.Rel against
validate.SafeInputPath(".") so FileIO.Open's existing SafeInputPath
guard (which rejects absolute paths) still applies.

Adds TestDriveStatusDoesNotEscapeViaSymlinkParentRef as a regression:
it stages an "escape" sibling directory containing a sentinel file,
adds a "link" symlink in cwd pointing into the escape directory, and
runs +status with --local-dir "link/..". Without this fix, the raw
walk visits the sentinel and leaks it into new_local; with the fix,
the walk stays inside the canonical cwd.

A standalone repro confirms the underlying behavior: raw
filepath.WalkDir("link/..", ...) traversed dozens of unrelated files
in the kernel-resolved parent directory; walking the canonical root
visits only the legitimate cwd contents.

* test(drive): pin walker behavior on child / circular symlinks for +status

Adds two corner-case regressions to back up the canonical-root walk fix:

- TestDriveStatusSkipsSymlinkInsideRoot: a child symlink under
  --local-dir that points to a sibling temp dir outside cwd. WalkDir's
  default policy must report it as a non-regular entry so the callback
  skips it, and the sentinel inside the target must not surface in
  new_local. This pins the contract our caller relies on (walk
  declines to follow child symlinks even when the canonical root
  resolves cleanly).

- TestDriveStatusSurvivesCircularSymlinkInsideRoot: a child symlink
  pointing back at one of its ancestors. The walk must terminate and
  surface the legitimate sibling file; if WalkDir ever followed the
  loop, the per-test timeout would catch it.

* fix(drive): close +status review gaps from Codex (pagination, doc, live E2E)

Three independent fixes flagged on PR #692:

1. Route the recursive Drive folder listing through common.PaginationMeta
   instead of reading next_page_token directly. The shared helper accepts
   both page_token and next_page_token, matching what okr/im already do
   and keeping +status safe against a backend field rename. Adds
   TestDriveStatusPaginatesRemoteListing, which serves a 2-page response
   where page 1 advertises the cursor as next_page_token and page 2 as
   page_token; either spelling alone would silently drop one page.

2. The skill doc previously suggested "or symlink the target into cwd"
   as a workaround for cwd-relative --local-dir. SafeInputPath calls
   filepath.EvalSymlinks before checking isUnderDir(canonicalCwd), so
   any symlink whose final target sits outside cwd still gets rejected
   as `unsafe file path`. Rewrite the section so agents stop steering
   users into a path that always errors out.

3. Add tests/cli_e2e/drive/drive_status_workflow_test.go — the live
   E2E that AGENTS.md requires for new shortcuts. Seeds a real Drive
   folder with three uploaded files (unchanged.txt, modified.txt,
   remote-only.txt), seeds a local tree with matching/diverging
   content plus a local-only.txt, runs +status, and asserts each of
   the four buckets contains exactly the file we expect with the
   right file_token. Cleanup of every uploaded file plus the parent
   folder is registered through the existing best-effort cleanup
   helpers. Coverage table bumped: drive +status moves to ✓ and the
   denominator goes from 28→29 to account for the new shortcut.

Codex also flagged the local-side filepath.WalkDir as a vfs-bypass.
Investigated: the depguard rule shortcuts-no-vfs explicitly forbids
shortcuts from importing internal/vfs (see commit c1b0bed on the
+pull branch where the same migration was rejected by CI). The
filepath.WalkDir + nolint:forbidigo pattern in walkLocalForStatus is
the lint-required convention until FileIO grows a walker, so leaving
it as-is.
2026-04-30 14:27:25 +08:00
calendar-assistant
0250054a90 feat(minutes): add media upload shortcut (#725)
Support minutes +upload to generate a minute from an uploaded media file token.

Change-Id: I59c0719a39541134e395a23262aea7f387105715

Co-authored-by: calendar-assistant <calendar-assistant@users.noreply.github.com>
2026-04-30 11:19:22 +08:00
SunPeiYang996
d7ee5b5769 feat: guide lark-doc v2 usage (#710)
## Summary
Add explicit guidance on the parent `docs` command so agents pick the right
lark-doc API version. Without this, agents that have an older lark-doc skill
installed can mistakenly mix v2 flags into a v1 flow.

## Changes
- Add `--api-version` help flag and a Tips section to `docs` so `lark docs --help`
  (and `--api-version v2`) explain when v2 should be used.
- Refresh the lark-doc skill references and `docs_fetch_v2` keyword flag
  description for clarity.
- Add `shortcuts/register_test.go` covering the new docs help wiring.

## Test Plan
- [x] Unit tests pass (`go test ./shortcuts/...`)
- [x] Manual local verification confirms the `lark docs --help` and
      `lark docs --help --api-version v2` commands work as expected

## Related Issues
- None

Change-Id: Id3b3196e6a069bb52f95a6fc679b8258313faf3d
2026-04-29 22:40:20 +08:00
103 changed files with 10572 additions and 411 deletions

View File

@@ -2,6 +2,25 @@
All notable changes to this project will be documented in this file.
## [v1.0.23] - 2026-04-30
### Features
- **drive**: Add `+pull` shortcut for one-way Drive → local mirror (#696)
- **drive**: Add `+push` shortcut for one-way local → Drive mirror (#709)
- **drive**: Add `+status` shortcut for content-hash diff (#692)
- **drive**: Support `--file-name` for drive export (#685)
- **base**: Add markdown output for record reads (#726)
- **minutes**: Add media upload shortcut (#725)
- **doc**: Warn when callout uses `type=` without `background-color` (#467)
- **cmdutil**: Support `@file` for params and data (#724)
- Add markdown shortcuts and skill docs (#704)
### Documentation
- **doc**: Guide lark-doc v2 usage (#710)
- **minutes**: Clarify minutes file-to-notes routing (#732)
## [v1.0.22] - 2026-04-29
### Features
@@ -560,6 +579,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 23 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 23 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 16 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -28,6 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
@@ -139,6 +140,7 @@ lark-cli auth status
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 23 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 16 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -28,6 +28,7 @@
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
@@ -140,6 +141,7 @@ lark-cli auth status
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |

View File

@@ -81,8 +81,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
@@ -112,6 +112,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
@@ -123,7 +124,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -145,7 +146,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// Parse --data as JSON map for form fields (not as body).
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -161,7 +162,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fileIO,
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
@@ -171,7 +172,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
// Normal path: JSON body.
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}

View File

@@ -122,5 +122,5 @@ func getLoginMsg(lang string) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs"}
return []string{"base", "contact", "docs", "markdown"}
}

View File

@@ -167,10 +167,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -354,6 +354,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
// Validate --file mutual exclusions.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
@@ -362,7 +363,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -431,7 +432,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
// Parse --data as form fields.
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -447,7 +448,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fileIO,
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
@@ -456,7 +457,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
request.Data = fd
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}

View File

@@ -7,19 +7,20 @@ import (
"encoding/json"
"io"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Supports stdin (-) and single-quote stripping via ResolveInput.
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.FileIO) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
resolved, err := ResolveInput(data, stdin)
resolved, err := ResolveInput(data, stdin, fileIO)
if err != nil {
return nil, output.ErrValidation("--data: %s", err)
}
@@ -34,9 +35,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, e
}
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
// Supports stdin (-) and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin)
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin, fileIO)
if err != nil {
return nil, output.ErrValidation("%s: %s", label, err)
}

View File

@@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data, nil)
got, err := ParseOptionalBody(tt.method, tt.data, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label, nil)
got, err := ParseJSONMap(tt.input, tt.label, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -4,19 +4,27 @@
package cmdutil
import (
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/extension/fileio"
)
// ResolveInput resolves special input conventions for a raw flag value:
// - "-" → read all bytes from stdin
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
// - "-" → read all bytes from stdin
// - "@<path>" → read all bytes from the file at <path> via fileIO
// - "@@..." → strip leading @ (escape for a literal @-prefixed value)
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
//
// This allows callers to bypass shell quoting issues (especially on Windows
// PowerShell) by piping JSON via stdin instead of command-line arguments.
func ResolveInput(raw string, stdin io.Reader) (string, error) {
// fileIO is required for "@<path>" inputs and goes through path validation
// (SafeInputPath); pass nil only when callers know "@" inputs are not possible.
//
// Allows callers to bypass shell quoting issues (especially Windows PowerShell 5)
// by reading JSON from a file (@path) or piping via stdin (-).
func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, error) {
if raw == "" {
return "", nil
}
@@ -37,6 +45,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
return s, nil
}
// escape: @@... → literal @... (no file read)
if strings.HasPrefix(raw, "@@") {
return raw[1:], nil
}
// file: @path
if strings.HasPrefix(raw, "@") {
path := strings.TrimSpace(raw[1:])
if path == "" {
return "", fmt.Errorf("file path cannot be empty after @")
}
data, err := ReadInputFile(fileIO, path)
if err != nil {
return "", err
}
s := strings.TrimSpace(string(data))
if s == "" {
return "", fmt.Errorf("file %q is empty", path)
}
return s, nil
}
// strip surrounding single quotes (Windows cmd.exe passes them literally)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
raw = raw[1 : len(raw)-1]
@@ -44,3 +74,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
return raw, nil
}
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")
}
f, err := fileIO.Open(path)
if err != nil {
return nil, wrapInputFileError(path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, wrapInputFileError(path, err)
}
return data, nil
}
func wrapInputFileError(path string, err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return fmt.Errorf("invalid file path %q: %w", path, err)
}
return fmt.Errorf("cannot read file %q: %w", path, err)
}

View File

@@ -5,12 +5,15 @@ package cmdutil
import (
"fmt"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
func TestResolveInput_Stdin(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -20,7 +23,7 @@ func TestResolveInput_Stdin(t *testing.T) {
}
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -30,7 +33,7 @@ func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
}
func TestResolveInput_Stdin_Empty(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(""))
_, err := ResolveInput("-", strings.NewReader(""), nil)
if err == nil {
t.Error("expected error for empty stdin")
}
@@ -44,21 +47,21 @@ type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
func TestResolveInput_Stdin_ReadError(t *testing.T) {
_, err := ResolveInput("-", errorReader{})
_, err := ResolveInput("-", errorReader{}, nil)
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "), nil)
if err == nil {
t.Error("expected error for whitespace-only stdin")
}
}
func TestResolveInput_Stdin_Nil(t *testing.T) {
_, err := ResolveInput("-", nil)
_, err := ResolveInput("-", nil, nil)
if err == nil {
t.Error("expected error for nil stdin")
}
@@ -77,7 +80,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveInput(tt.in, nil)
got, err := ResolveInput(tt.in, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -89,7 +92,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
}
func TestResolveInput_Empty(t *testing.T) {
got, err := ResolveInput("", nil)
got, err := ResolveInput("", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -99,7 +102,7 @@ func TestResolveInput_Empty(t *testing.T) {
}
func TestResolveInput_PlainValue(t *testing.T) {
got, err := ResolveInput(`{"already":"valid"}`, nil)
got, err := ResolveInput(`{"already":"valid"}`, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -108,21 +111,103 @@ func TestResolveInput_PlainValue(t *testing.T) {
}
}
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
// Without @file support, @-prefixed values are passed as-is
got, err := ResolveInput("@something", nil)
func TestResolveInput_AtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveInput("@params.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@something" {
t.Errorf("got %q, want %q", got, "@something")
if got != `{"folder_token":"abc123"}` {
t.Errorf("got %q", got)
}
}
func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("p.json", []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveInput("@p.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"k":"v"}` {
t.Errorf("got %q", got)
}
}
func TestResolveInput_AtFile_NotFound(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
_, err := ResolveInput("@missing.json", nil, fio)
if err == nil || !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_AtFile_PathValidation(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
// Absolute paths are rejected by SafeInputPath; the error must surface
// as an invalid-path message, not a generic read failure.
_, err := ResolveInput("@/etc/passwd", nil, fio)
if err == nil || !strings.Contains(err.Error(), "invalid file path") {
t.Errorf("expected path-validation error, got: %v", err)
}
}
func TestResolveInput_AtFile_EmptyPath(t *testing.T) {
fio := &localfileio.LocalFileIO{}
_, err := ResolveInput("@", nil, fio)
if err == nil || !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("expected empty-path error, got: %v", err)
}
}
func TestResolveInput_AtFile_EmptyContent(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("empty.json", []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
_, err := ResolveInput("@empty.json", nil, fio)
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Errorf("expected empty-file error, got: %v", err)
}
}
func TestResolveInput_AtFile_NoFileIO(t *testing.T) {
// When fileIO is nil, @path must error rather than silently fall back.
_, err := ResolveInput("@params.json", nil, nil)
if err == nil || !strings.Contains(err.Error(), "not available") {
t.Errorf("expected unavailable error, got: %v", err)
}
}
func TestResolveInput_DoubleAtEscape(t *testing.T) {
got, err := ResolveInput("@@literal", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@literal" {
t.Errorf("got %q, want %q", got, "@literal")
}
}
// Integration: ResolveInput flows through ParseJSONMap correctly.
func TestParseJSONMap_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
got, err := ParseJSONMap("-", "--params", stdin)
got, err := ParseJSONMap("-", "--params", stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -131,8 +216,48 @@ func TestParseJSONMap_WithStdin(t *testing.T) {
}
}
// Integration: @file flows through ParseJSONMap correctly.
func TestParseJSONMap_WithAtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ParseJSONMap("@params.json", "--params", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d keys, want 2", len(got))
}
if got["folder_token"] != "abc123" {
t.Errorf("got %v, want folder_token=abc123", got)
}
}
func TestParseOptionalBody_WithAtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("data.json", []byte(`{"text":"hello"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ParseOptionalBody("POST", "@data.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", got)
}
if m["text"] != "hello" {
t.Errorf("got %v, want text=hello", m)
}
}
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -143,7 +268,7 @@ func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
func TestParseOptionalBody_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"text":"hello"}`)
got, err := ParseOptionalBody("POST", "-", stdin)
got, err := ParseOptionalBody("POST", "-", stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -176,7 +301,7 @@ func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, "--params", nil)
got, err := ParseJSONMap(tt.input, "--params", nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -35,6 +35,10 @@
"en": { "title": "Mail", "description": "Email, draft, folder, and contacts management" },
"zh": { "title": "邮箱", "description": "查看和管理用户邮箱数据,包括邮件、草稿、文件夹和联系人" }
},
"markdown": {
"en": { "title": "Markdown", "description": "Drive-native Markdown file create, fetch, and overwrite" },
"zh": { "title": "Markdown", "description": "Drive 原生 Markdown 文件的创建、读取和覆盖更新" }
},
"minutes": {
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }

View File

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

View File

@@ -827,28 +827,6 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
}
func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("list", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"records": map[string]interface{}{
"schema": []interface{}{"Name", "Age"},
"record_ids": []interface{}{"rec_1"},
"rows": []interface{}{[]interface{}{"Alice", 18}},
}},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"records"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list with fields and view", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -864,7 +842,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
@@ -887,7 +865,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
@@ -895,7 +873,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list new shape", func(t *testing.T) {
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
@@ -904,13 +882,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"field_id_list": []interface{}{"fld_name", "fld_age"},
"record_id_list": []interface{}{"rec_2"},
"data": []interface{}{[]interface{}{"Bob", 20}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Bob"`) || !strings.Contains(got, `"rec_2"`) {
@@ -918,6 +897,47 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=2&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"field_id_list": []interface{}{"fld_name", "fld_age"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{
[]interface{}{"Alice", 18},
[]interface{}{"Bob", 20},
},
"has_more": false,
"query_context": map[string]interface{}{
"record_scope": "all_records",
"field_scope": "selected_fields",
},
"ignored_fields": []interface{}{"Formula"},
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "2", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"`_record_id` is metadata for record operations, not a table field.",
"| _record_id | Name | Age |",
"| rec_1 | Alice | 18 |",
"Meta: count=2; has_more=false; record_scope=all_records; field_scope=selected_fields; ignored_fields=1",
"Ignored fields: Formula",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
t.Run("search", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
@@ -948,6 +968,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
"--format", "json",
},
factory,
stdout,
@@ -968,6 +989,53 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("search markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title", "Owner"},
"field_id_list": []interface{}{"fld_title", "fld_owner"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Created by AI", "Alice"}},
"has_more": false,
"query_context": map[string]interface{}{
"record_scope": "view_filtered_records",
"field_scope": "selected_fields",
"search_scope": "fld_title(Title)",
},
},
},
})
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"keyword":"Created","search_fields":["Title"],"select_fields":["Title","Owner"],"limit":2}`,
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"| _record_id | Title | Owner |",
"| rec_1 | Created by AI | Alice |",
"Meta: count=1; has_more=false; record_scope=view_filtered_records; field_scope=selected_fields; search_scope=fld_title(Title)",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
@@ -1674,7 +1742,7 @@ func TestBaseRecordExecuteListWithViewPagination(t *testing.T) {
}, "total": 201},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_last"`) || !strings.Contains(got, `"total": 201`) {

View File

@@ -18,7 +18,7 @@ var BaseFormDelete = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},

View File

@@ -20,7 +20,7 @@ var BaseFormGet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},

View File

@@ -21,7 +21,7 @@ var BaseFormQuestionsList = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},

View File

@@ -210,6 +210,102 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
}
}
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
wantTips []string
}{
{
name: "record list",
shortcut: BaseRecordList,
wantHelp: []string{
"field ID or name to include; repeat to project only needed fields",
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
"pagination size, range 1-200",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
"Default output is markdown",
"Use --field-id repeatedly to keep output small",
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
"lark-base record read SOP",
},
},
{
name: "record search",
shortcut: BaseRecordSearch,
wantHelp: []string{
"requires keyword/search_fields",
"optional select_fields/view_id/offset/limit",
"output format: markdown (default) | json",
},
wantTips: []string{
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
`"select_fields":["Name","Status"]`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
"search_fields length 1-20",
"limit range 1-200 defaults to 10",
"view_id scopes search to records in that view",
"Default output is markdown",
"only for keyword search",
"lark-base record read SOP",
},
},
{
name: "record get",
shortcut: BaseRecordGet,
wantHelp: []string{
"record ID",
},
wantTips: []string{
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"record_id is already known",
"lark-base record read SOP",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
for _, want := range tt.wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
assertHelpOrder(t, help, "base token", "output format")
assertHelpOrder(t, help, "table ID", "output format")
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func assertHelpOrder(t *testing.T, help string, before string, after string) {
t.Helper()
beforeIndex := strings.Index(help, before)
afterIndex := strings.Index(help, after)
if beforeIndex < 0 || afterIndex < 0 {
return
}
if beforeIndex > afterIndex {
t.Fatalf("flag help order mismatch: %q should appear before %q:\n%s", before, after, help)
}
}
func TestBaseFieldValidate(t *testing.T) {
ctx := context.Background()
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {

10
shortcuts/base/help.go Normal file
View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import "github.com/spf13/cobra"
func preserveFlagOrder(cmd *cobra.Command) {
cmd.Flags().SortFlags = false
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var BaseRecordGet = common.Shortcut{
@@ -21,7 +22,15 @@ var BaseRecordGet = common.Shortcut{
tableRefFlag(true),
recordRefFlag(true),
},
Tips: []string{
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"Use +record-get when record_id is already known; otherwise use +record-search or +record-list.",
"Agent hint: follow the lark-base record read SOP for record read routing.",
},
DryRun: dryRunRecordGet,
PostMount: func(cmd *cobra.Command) {
preserveFlagOrder(cmd)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordGet(runtime)
},

View File

@@ -7,6 +7,7 @@ import (
"context"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var BaseRecordList = common.Shortcut{
@@ -19,13 +20,46 @@ var BaseRecordList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "field-id", Type: "string_array", Desc: "field ID or field name to include (repeatable)"},
{Name: "view-id", Desc: "view ID"},
recordListFieldRefFlag(),
recordListViewRefFlag(),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
recordReadFormatFlag(),
},
Tips: []string{
"Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use --field-id repeatedly to keep output small and aligned with the task.",
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.",
"For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.",
},
DryRun: dryRunRecordList,
PostMount: func(cmd *cobra.Command) {
preserveFlagOrder(cmd)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordList(runtime)
},
}
func recordListFieldRefFlag() common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = "field ID or name to include; repeat to project only needed fields"
return flag
}
func recordListViewRefFlag() common.Flag {
flag := viewRefFlag(false)
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
return flag
}
func recordReadFormatFlag() common.Flag {
return common.Flag{
Name: "format",
Default: "markdown",
Desc: "output format: markdown (default) | json",
}
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRecordMarkdownIgnoredFields = 20
func validateRecordReadFormat(runtime *common.RuntimeContext) error {
switch runtime.Str("format") {
case "", "json", "markdown":
return nil
default:
return output.ErrValidation("--format must be json or markdown")
}
}
func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
if runtime.JqExpr != "" {
if !runtime.Changed("format") {
runtime.Out(data, nil)
return nil
}
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
}
rendered, err := renderRecordMarkdown(data)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
runtime.Out(data, nil)
return nil
}
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
}
fmt.Fprint(runtime.IO().Out, rendered)
return nil
}
func renderRecordMarkdown(data map[string]interface{}) (string, error) {
fields := stringSliceValue(data["fields"])
recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok {
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
}
var b strings.Builder
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
columns := append([]string{"_record_id"}, fields...)
writeMarkdownRow(&b, columns)
writeMarkdownSeparator(&b, len(columns))
for i, rowValue := range rows {
rowItems, _ := rowValue.([]interface{})
cells := make([]string, 0, len(columns))
if i < len(recordIDs) {
cells = append(cells, recordIDs[i])
} else {
cells = append(cells, "")
}
for j := range fields {
if j < len(rowItems) {
cells = append(cells, markdownCell(rowItems[j]))
} else {
cells = append(cells, "")
}
}
writeMarkdownRow(&b, cells)
}
meta := recordMarkdownMeta(data)
if len(meta) > 0 {
b.WriteString("\nMeta: ")
b.WriteString(strings.Join(meta, "; "))
b.WriteByte('\n')
}
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
b.WriteString("Ignored fields: ")
b.WriteString(ignored)
b.WriteByte('\n')
}
return b.String(), nil
}
func recordMarkdownMeta(data map[string]interface{}) []string {
meta := []string{fmt.Sprintf("count=%d", ignoredFieldsCount(data["record_id_list"]))}
if hasMore, ok := data["has_more"]; ok {
meta = append(meta, "has_more="+markdownInlineValue(hasMore))
}
if queryContext, ok := data["query_context"].(map[string]interface{}); ok {
for _, key := range []string{"record_scope", "field_scope", "search_scope"} {
if value, ok := queryContext[key]; ok {
meta = append(meta, key+"="+markdownInlineValue(value))
}
}
}
if ignoredCount := ignoredFieldsCount(data["ignored_fields"]); ignoredCount > 0 {
meta = append(meta, fmt.Sprintf("ignored_fields=%d", ignoredCount))
}
return meta
}
func ignoredFieldsCount(value interface{}) int {
switch v := value.(type) {
case []interface{}:
return len(v)
case []string:
return len(v)
case nil:
return 0
default:
return 1
}
}
func ignoredFieldsMarkdown(value interface{}) string {
items := markdownListItems(value)
if len(items) == 0 {
return ""
}
total := len(items)
if len(items) > maxRecordMarkdownIgnoredFields {
items = items[:maxRecordMarkdownIgnoredFields]
items = append(items, fmt.Sprintf("...(%d total)", total))
}
return strings.Join(items, ", ")
}
func markdownListItems(value interface{}) []string {
switch v := value.(type) {
case []interface{}:
items := make([]string, 0, len(v))
for _, item := range v {
items = append(items, markdownInlineValue(item))
}
return items
case []string:
items := make([]string, 0, len(v))
for _, item := range v {
items = append(items, markdownInlineValue(item))
}
return items
case nil:
return nil
default:
return []string{markdownInlineValue(v)}
}
}
func writeMarkdownRow(b *strings.Builder, cells []string) {
b.WriteString("| ")
for i, cell := range cells {
if i > 0 {
b.WriteString(" | ")
}
b.WriteString(markdownTableText(cell))
}
b.WriteString(" |\n")
}
func writeMarkdownSeparator(b *strings.Builder, columns int) {
b.WriteString("| ")
for i := 0; i < columns; i++ {
if i > 0 {
b.WriteString(" | ")
}
b.WriteString("---")
}
b.WriteString(" |\n")
}
func markdownCell(value interface{}) string {
return markdownInlineValue(value)
}
func markdownInlineValue(value interface{}) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
case json.Number:
return v.String()
case bool:
if v {
return "true"
}
return "false"
case float64:
return fmt.Sprintf("%v", v)
case int:
return fmt.Sprintf("%d", v)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}
func markdownTableText(value string) string {
value = strings.ReplaceAll(value, "\\", "\\\\")
value = strings.ReplaceAll(value, "|", "\\|")
value = strings.ReplaceAll(value, "\r\n", "<br>")
value = strings.ReplaceAll(value, "\n", "<br>")
return value
}
func stringSliceValue(value interface{}) []string {
switch v := value.(type) {
case []interface{}:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
case []string:
return append([]string(nil), v...)
default:
return nil
}
}

View File

@@ -0,0 +1,229 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type recordMarkdownCSTestProvider struct {
alert *extcs.Alert
}
func (p *recordMarkdownCSTestProvider) Name() string { return "test" }
func (p *recordMarkdownCSTestProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, nil
}
func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeContext {
parentCmd := &cobra.Command{Use: "lark-cli"}
baseCmd := &cobra.Command{Use: "base"}
cmd := &cobra.Command{Use: "+record-list"}
cmd.Flags().String("format", "markdown", "")
parentCmd.AddCommand(baseCmd)
baseCmd.AddCommand(cmd)
return &common.RuntimeContext{
Config: &core.CliConfig{Brand: core.BrandFeishu},
Cmd: cmd,
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}},
}
}
func TestRenderRecordMarkdownEmptyResult(t *testing.T) {
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{},
"data": []interface{}{},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"| _record_id | Name | Age |",
"Meta: count=0; has_more=false",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordMarkdownEscapesTableCells(t *testing.T) {
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name|Label", "Note"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"| _record_id | Name\\|Label | Note |",
"| rec_1 | A\\|B | line1<br>line2 |",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordMarkdownTruncatesIgnoredFields(t *testing.T) {
ignored := make([]interface{}, maxRecordMarkdownIgnoredFields+2)
for i := range ignored {
ignored[i] = fmt.Sprintf("Field%d", i+1)
}
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
"ignored_fields": ignored,
})
if err != nil {
t.Fatalf("err=%v", err)
}
if !strings.Contains(got, fmt.Sprintf("ignored_fields=%d", len(ignored))) ||
!strings.Contains(got, fmt.Sprintf("...(%d total)", len(ignored))) ||
strings.Contains(got, "Field22") {
t.Fatalf("ignored field truncation mismatch:\n%s", got)
}
}
func TestOutputRecordMarkdownContentSafetyWarnKeepsStdoutClean(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&recordMarkdownCSTestProvider{
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
})
defer extcs.Register(nil)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
if err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "| rec_1 | Alice |") || strings.Contains(got, "content safety") {
t.Fatalf("stdout should contain only markdown data, got:\n%s", got)
}
if got := stderr.String(); !strings.Contains(got, "warning: content safety alert") {
t.Fatalf("stderr missing content safety warning:\n%s", got)
}
}
func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
extcs.Register(&recordMarkdownCSTestProvider{
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
})
defer extcs.Register(nil)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
t.Fatalf("err=%v, want content safety exit error", err)
}
if stdout.Len() > 0 {
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())
}
if stderr.Len() > 0 {
t.Fatalf("block mode should not write warning to stderr, got:\n%s", stderr.String())
}
}
func TestOutputRecordMarkdownFallsBackToJSONWhenRenderFails(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
"records": map[string]interface{}{
"schema": []interface{}{"Name"},
"rows": []interface{}{[]interface{}{"Alice"}},
},
})
if err != nil {
t.Fatalf("err=%v", err)
}
if strings.Contains(stdout.String(), "markdown render failed") {
t.Fatalf("stdout should not contain fallback warning:\n%s", stdout.String())
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("stdout should be JSON fallback, got err=%v stdout=%s", err, stdout.String())
}
if !env.OK || !strings.Contains(stdout.String(), `"records"`) {
t.Fatalf("stdout missing JSON fallback data:\n%s", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "warning: record markdown render failed, falling back to json") {
t.Fatalf("stderr missing fallback warning:\n%s", got)
}
}
func TestOutputRecordMarkdownDefaultFormatAllowsJqJSONFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
runtime.JqExpr = ".data.record_id_list[0]"
err := outputRecordMarkdown(runtime, map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
if err != nil {
t.Fatalf("err=%v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "rec_1" {
t.Fatalf("stdout jq fallback mismatch: %q", got)
}
if stderr.Len() > 0 {
t.Fatalf("stderr should be empty, got:\n%s", stderr.String())
}
}
func TestOutputRecordMarkdownExplicitFormatRejectsJq(t *testing.T) {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
runtime.JqExpr = ".data"
if err := runtime.Cmd.Flags().Set("format", "markdown"); err != nil {
t.Fatalf("set format: %v", err)
}
err := outputRecordMarkdown(runtime, map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
if err == nil || !strings.Contains(err.Error(), "--jq and --format markdown are mutually exclusive") {
t.Fatalf("err=%v, want jq markdown conflict", err)
}
if stdout.Len() > 0 {
t.Fatalf("stdout should be empty, got:\n%s", stdout.String())
}
}

View File

@@ -173,6 +173,9 @@ func recordListFields(runtime *common.RuntimeContext) []string {
}
func executeRecordList(runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
offset := runtime.Int("offset")
if offset < 0 {
offset = 0
@@ -190,6 +193,9 @@ func executeRecordList(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if runtime.Str("format") == "markdown" {
return outputRecordMarkdown(runtime, data)
}
runtime.Out(data, nil)
return nil
}
@@ -213,6 +219,9 @@ func executeRecordSearch(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if runtime.Str("format") == "markdown" {
return outputRecordMarkdown(runtime, data)
}
runtime.Out(data, nil)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var BaseRecordSearch = common.Shortcut{
@@ -19,16 +20,28 @@ var BaseRecordSearch = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "record search JSON object", Required: true},
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true},
recordReadFormatFlag(),
},
Tips: []string{
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.",
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordSearch,
PostMount: func(cmd *cobra.Command) {
preserveFlagOrder(cmd)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordSearch(runtime)
},

View File

@@ -155,6 +155,19 @@ func (ctx *RuntimeContext) LarkSDK() *lark.Client {
return ctx.larkSDK
}
// EnsureScopes runs the same pre-flight scope check used by the framework
// before Validate, but on a caller-supplied set of scopes. Use it from a
// shortcut's Validate to enforce conditional scope requirements that depend
// on flag values (e.g. --delete-remote needing space:document:delete) so a
// destructive operation never starts on a token that can't finish it.
//
// Behavior matches checkShortcutScopes: when no token is available or the
// resolver doesn't expose scope metadata, this is a silent no-op — the
// downstream API call still surfaces missing_scope at runtime.
func (ctx *RuntimeContext) EnsureScopes(scopes []string) error {
return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes)
}
// ── Flag accessors ──
// Str returns a string flag value.
@@ -882,17 +895,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if path == "" {
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
}
f, err := rctx.FileIO().Open(path)
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return FlagErrorf("--%s: invalid file path %q: %v", fl.Name, path, err)
}
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
}
data, err := io.ReadAll(f)
f.Close()
if err != nil {
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
return FlagErrorf("--%s: %v", fl.Name, err)
}
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue

View File

@@ -5,7 +5,6 @@ package common
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -60,13 +59,12 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
func TestResolveInputFlags_File(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
cmdutil.TestChdir(t, dir)
content := "## Hello\n\nThis is **markdown** from a file.\n"
fpath := filepath.Join(dir, "test.md")
os.WriteFile(fpath, []byte(content), 0644)
if err := os.WriteFile("test.md", []byte(content), 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@test.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -79,6 +77,25 @@ func TestResolveInputFlags_File(t *testing.T) {
}
}
func TestResolveInputFlags_EmptyFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("empty.md", nil, 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@empty.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
func TestResolveInputFlags_EmptyInput(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": ""}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -132,9 +149,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
func TestResolveInputFlags_FileNotFound(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
cmdutil.TestChdir(t, dir)
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@nonexistent.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -156,7 +171,7 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty file path")
}
if !strings.Contains(err.Error(), "file path cannot be empty") {
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("unexpected error: %v", err)
}
}

View File

@@ -43,6 +43,7 @@ var DocsCreate = common.Shortcut{
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
@@ -110,6 +111,11 @@ func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.D
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+create")
// Surface callout type= hint so users know to switch to background-color/
// border-color when they want a colored callout. Non-blocking, advisory.
if md := runtime.Str("markdown"); md != "" {
WarnCalloutType(md, runtime.IO().ErrOut)
}
args := buildCreateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
@@ -122,8 +128,9 @@ func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
}
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
md := runtime.Str("markdown")
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
"markdown": md,
}
if v := runtime.Str("title"); v != "" {
args["title"] = v

View File

@@ -49,6 +49,7 @@ var DocsFetch = common.Shortcut{
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},

View File

@@ -22,7 +22,7 @@ func v2FetchFlags() []common.Flag {
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
{Name: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},

View File

@@ -64,6 +64,7 @@ var DocsUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
@@ -159,6 +160,12 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
}
// Surface callout type= hint so users know to switch to background-color/
// border-color when they want a colored callout. Non-blocking, advisory.
if md := runtime.Str("markdown"); md != "" {
WarnCalloutType(md, runtime.IO().ErrOut)
}
args := buildUpdateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "update-doc", args)

View File

@@ -4,6 +4,8 @@
package doc
import (
"fmt"
"io"
"regexp"
"strings"
"unicode"
@@ -306,6 +308,113 @@ func fixSetextAmbiguity(md string) string {
return setextRe.ReplaceAllString(md, "$1\n\n$2")
}
// calloutTypeColors maps the semantic type= shorthand to a recommended
// [background-color, border-color] pair for Feishu callout blocks.
// Used only for hint messages — the Markdown itself is never rewritten.
var calloutTypeColors = map[string][2]string{
"warning": {"light-yellow", "yellow"},
"caution": {"light-orange", "orange"},
"note": {"light-blue", "blue"},
"info": {"light-blue", "blue"},
"tip": {"light-green", "green"},
"success": {"light-green", "green"},
"check": {"light-green", "green"},
"error": {"light-red", "red"},
"danger": {"light-red", "red"},
"important": {"light-purple", "purple"},
}
// calloutOpenTagRe matches a <callout …> opening tag.
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)
// calloutTypeAttrRe extracts the value of a type= attribute (single or
// double quoted) from a callout opening tag's attribute string. The
// (?:^|\s) anchor instead of \b is intentional: \b sits at any
// word/non-word boundary, and `-` is a non-word character, so
// `\btype=` would also match the suffix of `data-type=` and yield a
// bogus type lookup. Anchoring on start-of-string-or-whitespace
// requires a real attribute separator before the name.
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)
// calloutBackgroundColorAttrRe matches a background-color= attribute
// name with optional whitespace around the equals sign, so forms like
// `background-color="..."` and `background-color = "..."` are both
// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same
// reason: `data-background-color="..."` must not look like a present
// background-color and silently suppress the hint.
var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`)
// WarnCalloutType scans md for callout tags that carry a type= attribute but
// no background-color= attribute, then writes a hint line to w for each one
// suggesting the explicit Feishu color attributes to use instead.
//
// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they
// are documentation samples, not real callouts the user wants Feishu to
// render. Fence detection uses the shared codeFenceOpenMarker /
// isCodeFenceClose helpers so both backtick and tilde fences are handled
// (matching CommonMark §4.5).
//
// The Markdown is not modified — the caller is responsible for acting on
// the hints or ignoring them. This keeps the create/update path
// transparent: user input reaches create-doc exactly as written.
func WarnCalloutType(md string, w io.Writer) {
fenceMarker := ""
for _, line := range strings.Split(md, "\n") {
if fenceMarker != "" {
// Inside a fenced block — skip everything until the matching
// closer. Code samples that show literal <callout type=...>
// must not produce a phantom hint.
if isCodeFenceClose(line, fenceMarker) {
fenceMarker = ""
}
continue
}
if marker := codeFenceOpenMarker(line); marker != "" {
fenceMarker = marker
continue
}
scanCalloutTagsForWarning(line, w)
}
}
// scanCalloutTagsForWarning emits a hint to w for every <callout type="...">
// tag in s that lacks an explicit background-color= attribute. Pulled out
// of WarnCalloutType so the line walker only handles fence state and the
// per-tag scan is its own readable unit.
//
// The previous implementation routed the tag iteration through
// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always
// returned the original tag and threw the rebuilt string away — using a
// rewrite primitive purely for its iteration side-effect, plus a second
// regex execution to recover the capture groups inside the callback.
// FindAllStringSubmatch hands us both the iteration and the groups in one
// pass, no allocation thrown away.
func scanCalloutTagsForWarning(s string, w io.Writer) {
for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) {
attrs := m[1]
// Skip tags that already carry an explicit background-color.
if calloutBackgroundColorAttrRe.MatchString(attrs) {
continue
}
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
if len(parts) < 3 {
continue // no type= attribute
}
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
typeName := parts[1]
if typeName == "" {
typeName = parts[2]
}
colors, ok := calloutTypeColors[typeName]
if !ok {
continue // unknown type — no hint to give
}
fmt.Fprintf(w,
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
typeName, colors[0], colors[1])
}
}
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
// Unicode emoji characters that create-doc accepts.
var calloutEmojiAliases = map[string]string{

View File

@@ -359,6 +359,135 @@ func TestFixExportedMarkdown(t *testing.T) {
}
}
func TestWarnCalloutType(t *testing.T) {
tests := []struct {
name string
input string
wantHint bool // whether a hint line is expected
hintContains string // substring the hint must contain
}{
{
name: "warning type without background-color emits hint",
input: `<callout type="warning" emoji="📝">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
name: "info type without background-color emits hint",
input: `<callout type="info" emoji="">`,
wantHint: true,
hintContains: `background-color="light-blue"`,
},
{
name: "single-quoted type attribute emits hint",
input: `<callout type='warning' emoji="📝">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
name: "explicit background-color suppresses hint",
input: `<callout type="warning" emoji="📝" background-color="light-red">`,
wantHint: false,
},
{
name: "whitespace around equals is tolerated in background-color",
input: `<callout type="warning" emoji="📝" background-color = "light-red">`,
wantHint: false,
},
{
name: "unknown type emits no hint",
input: `<callout type="custom" emoji="🔥">`,
wantHint: false,
},
{
name: "no type attribute emits no hint",
input: `<callout emoji="💡" background-color="light-green">`,
wantHint: false,
},
{
name: "non-callout tag emits no hint",
input: `<div type="warning">`,
wantHint: false,
},
{
name: "hint includes border-color suggestion",
input: `<callout type="error" emoji="❌">`,
wantHint: true,
hintContains: `border-color="red"`,
},
{
// Regression: the old `\btype=` regex matched the suffix of
// `data-type=` because `-` is a non-word character, so a tag
// carrying only data-attrs would silently get a bogus hint.
// The (?:^|\s) anchor requires a real attribute separator.
name: "data-type attribute does not trigger hint",
input: `<callout data-type="warning" emoji="📝">`,
wantHint: false,
},
{
// Symmetric guard for the background-color regex: a future
// `data-background-color=` attribute must not be mistaken
// for a present background-color and silently suppress the
// hint that the real type= would otherwise produce.
name: "data-background-color does not suppress hint",
input: `<callout type="warning" data-background-color="anything">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
// Regression for the code-fence skip: a documentation sample
// inside a ``` fence is NOT a real callout the user wants
// rendered, so it must produce no stderr noise.
name: "callout inside backtick fence emits no hint",
input: "```markdown\n" +
`<callout type="warning" emoji="📝">` + "\n" +
"```\n",
wantHint: false,
},
{
// Same skip works for tilde fences (CommonMark §4.5 makes
// `~~~` an equivalent fence character).
name: "callout inside tilde fence emits no hint",
input: "~~~markdown\n" +
`<callout type="info" emoji="">` + "\n" +
"~~~\n",
wantHint: false,
},
{
// Closing the fence must restore normal scanning: a real
// callout that follows a documentation block still gets a
// hint. Pins that fenceMarker is reset, not stuck.
name: "callout after fence close still emits hint",
input: "```markdown\n" +
`<callout type="warning">sample</callout>` + "\n" +
"```\n" +
`<callout type="error" emoji="❌">real</callout>` + "\n",
wantHint: true,
hintContains: `border-color="red"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
WarnCalloutType(tt.input, &buf)
got := buf.String()
if tt.wantHint {
if got == "" {
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
return
}
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
}
} else {
if got != "" {
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
}
}
})
}
}
func TestFixCalloutEmoji(t *testing.T) {
tests := []struct {
name string

View File

@@ -3,7 +3,24 @@
package doc
import "github.com/larksuite/cli/shortcuts/common"
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
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.",
}
// Shortcuts returns all docs shortcuts.
func Shortcuts() []common.Shortcut {
@@ -18,3 +35,48 @@ func Shortcuts() []common.Shortcut {
DocMediaDownload,
}
}
// 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`.
func ConfigureServiceHelp(cmd *cobra.Command) {
if cmd == nil {
return
}
serviceCmd := cmd
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
if cmd.Flags().Lookup("api-version") == nil {
cmd.Flags().String("api-version", "", "show docs help for API version (v1|v2)")
cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp
})
}
defaultHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd != serviceCmd {
defaultHelp(cmd, args)
return
}
apiVersion, _ := cmd.Flags().GetString("api-version")
previousLong := cmd.Long
if apiVersion == "v2" {
cmd.Long = strings.TrimSpace(docsServiceHelpV2)
} else {
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
}
defer func() {
cmd.Long = previousLong
}()
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range docsVersionSelectionTips {
fmt.Fprintf(out, " • %s\n", tip)
}
})
}

View File

@@ -30,13 +30,6 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
}
})
origHelp(cmd, args)
if ver == "v1" {
fmt.Fprintf(cmd.OutOrStdout(),
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
" Use --api-version v2 for the latest API:\n"+
" %s %s --api-version v2 --help\n",
cmd.Parent().Name(), cmd.Name())
}
})
}

View File

@@ -33,6 +33,7 @@ var DriveExport = common.Shortcut{
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}},
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "file-name", Desc: "preferred output filename (optional)"},
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
@@ -54,14 +55,19 @@ var DriveExport = common.Shortcut{
// Markdown export is a special case: docx markdown comes from docs content
// directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
return common.NewDryRunAPI().
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
GET("/open-apis/docs/v1/content").
Params(map[string]interface{}{
"doc_token": spec.Token,
"doc_type": "docx",
"content_type": "markdown",
})
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
body := map[string]interface{}{
@@ -73,10 +79,15 @@ var DriveExport = common.Shortcut{
body["sub_id"] = spec.SubID
}
return common.NewDryRunAPI().
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body)
Body(body).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveExportSpec{
@@ -86,6 +97,7 @@ var DriveExport = common.Shortcut{
SubID: runtime.Str("sub-id"),
}
outputDir := runtime.Str("output-dir")
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
overwrite := runtime.Bool("overwrite")
// Markdown export bypasses the async export task and writes the fetched
@@ -106,14 +118,18 @@ var DriveExport = common.Shortcut{
return err
}
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
}
fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
if err != nil {
return err
@@ -166,7 +182,11 @@ var DriveExport = common.Shortcut{
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
fileName := ensureExportFileExtension(sanitizeExportFileName(status.FileName, spec.Token), spec.FileExtension)
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
@@ -227,7 +247,7 @@ var DriveExport = common.Shortcut{
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
runtime.Out(map[string]interface{}{
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
@@ -238,7 +258,11 @@ var DriveExport = common.Shortcut{
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}, nil)
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
},

View File

@@ -130,6 +130,155 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
}
}
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# custom\n",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--file-name", "custom-notes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "# custom\n" {
t.Fatalf("markdown content = %q", string(data))
}
if !strings.Contains(stdout.String(), `"file_name": "custom-notes.md"`) {
t.Fatalf("stdout missing provided file name: %s", stdout.String())
}
}
func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
tests := []struct {
name string
wantURL string
wantFileName string
args []string
}{
{
name: "markdown",
wantURL: "/open-apis/docs/v1/content",
wantFileName: `"file_name": "notes.md"`,
args: []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--file-name", "notes",
"--output-dir", "./exports",
"--dry-run",
"--as", "bot",
},
},
{
name: "async export",
wantURL: "/open-apis/drive/v1/export_tasks",
wantFileName: `"file_name": "report.pdf"`,
args: []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "report",
"--output-dir", "./exports",
"--dry-run",
"--as", "bot",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveExport, tt.args, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, tt.wantURL) {
t.Fatalf("stdout missing URL %q: %s", tt.wantURL, out)
}
if !strings.Contains(out, tt.wantFileName) {
t.Fatalf("stdout missing file_name metadata %q: %s", tt.wantFileName, out)
}
if !strings.Contains(out, `"output_dir": "./exports"`) {
t.Fatalf("stdout missing output_dir metadata: %s", out)
}
})
}
}
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# fallback\n",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Status: 500,
Body: map[string]interface{}{
"code": 999,
"msg": "metadata unavailable",
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "# fallback\n" {
t.Fatalf("markdown content = %q", string(data))
}
if !strings.Contains(stdout.String(), `"file_name": "docx123.md"`) {
t.Fatalf("stdout missing fallback file name: %s", stdout.String())
}
}
func TestDriveExportAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -200,6 +349,77 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_custom"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_custom",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_custom",
"file_name": "server-name",
"file_extension": "pdf",
"type": "docx",
"file_size": 3,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_custom/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="server-name.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "custom-report",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-report.pdf"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "pdf" {
t.Fatalf("downloaded content = %q", string(data))
}
if !strings.Contains(stdout.String(), `"file_name": "custom-report.pdf"`) {
t.Fatalf("stdout missing provided file name: %s", stdout.String())
}
}
func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
@@ -425,6 +645,51 @@ func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
}
}
func TestDriveExportTimeoutPreservesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_name"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_name",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 2,
},
},
},
})
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "quarterly-report",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"file_name": "quarterly-report.pdf"`) {
t.Fatalf("stdout missing preserved file name: %s", stdout.String())
}
}
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -0,0 +1,337 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
drivePullIfExistsOverwrite = "overwrite"
drivePullIfExistsSkip = "skip"
)
type drivePullItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}
// DrivePull performs a one-way file-level mirror from a Drive folder onto
// a local directory: recursively lists --folder-token, downloads each
// type=file entry under --local-dir, and optionally deletes local files
// absent from Drive (--delete-local --yes).
//
// Only Drive entries with type=file participate; online docs (docx, sheet,
// bitable, mindnote, slides) and shortcuts are skipped because there is no
// equivalent local binary to write back. Directories are reproduced when
// remote folders contain downloadable files, but local directories that
// become orphaned after a remote folder is removed are NOT pruned —
// --delete-local only unlinks regular files.
var DrivePull = common.Shortcut{
Service: "drive",
Command: "+pull",
Description: "One-way file-level mirror of a Drive folder onto a local directory (Drive → local)",
Risk: "write",
Scopes: []string{"drive:drive.metadata:readonly", "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: "source Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
{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"},
},
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.",
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Recursively list --folder-token, download each type=file entry into --local-dir, and (when --delete-local --yes is set) remove local files absent from Drive.").
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"))
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
if ifExists == "" {
ifExists = drivePullIfExistsOverwrite
}
deleteLocal := runtime.Bool("delete-local")
// Resolve --local-dir to its canonical absolute path before we
// touch the filesystem. SafeInputPath fully evaluates symlinks
// across the entire path; this matters because filepath.Clean
// alone shrinks "link/.." to "." while the kernel resolves it
// through the symlink target's parent — meaning a raw walk on
// the user-supplied string can land outside cwd. Walking the
// canonical root sidesteps that, and using cwd canonical lets
// us emit cwd-relative download targets that FileIO.Save's
// SafeOutputPath check still accepts. The risk is much higher
// here than in +status because --delete-local would otherwise
// remove the wrong files outside cwd.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
// rootRelToCwd is the localDir form FileIO.Save accepts (it
// rejects absolute paths). For cwd itself it becomes ".", which
// joins cleanly with the rel_paths returned by the lister.
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
if err != nil {
return err
}
// Two views over the same listing:
// - remoteFiles drives the download/skip loop (only type=file
// has hashable bytes the local mirror can write back).
// - remotePaths is the --delete-local guard: it carries every
// rel_path Drive owns regardless of type, so a local file
// shadowed by a remote folder / online doc / shortcut is NOT
// treated as orphaned.
remoteFiles := make(map[string]string, len(entries))
remotePaths := make(map[string]struct{}, len(entries))
for rel, entry := range entries {
remotePaths[rel] = struct{}{}
if entry.Type == driveTypeFile {
remoteFiles[rel] = entry.FileToken
}
}
var downloaded, skipped, failed, deletedLocal int
downloadFailed := 0
items := make([]drivePullItem, 0)
// Deterministic iteration order for output stability.
downloadablePaths := make([]string, 0, len(remoteFiles))
for p := range remoteFiles {
downloadablePaths = append(downloadablePaths, p)
}
sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths {
token := remoteFiles[rel]
target := filepath.Join(rootRelToCwd, rel)
if info, statErr := runtime.FileIO().Stat(target); statErr == nil {
// Mirror conflict: remote is a regular file but local
// has a directory at the same rel_path. Neither
// "skipped" nor "downloaded" describes reality —
// SafeOutputPath would refuse to write a file over a
// directory, and pretending the directory is a
// pre-existing file under --if-exists=skip silently
// hides the conflict. Surface as a failure.
if info.IsDir() {
items = append(items, drivePullItem{
RelPath: rel,
FileToken: token,
Action: "failed",
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
})
failed++
downloadFailed++
continue
}
if ifExists == drivePullIfExistsSkip {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
skipped++
continue
}
}
if err := drivePullDownload(ctx, runtime, token, target); err != nil {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()})
failed++
downloadFailed++
continue
}
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
downloaded++
}
// Gate --delete-local on a clean download pass. With download
// failures still in items[], proceeding to the delete walk would
// leave the mirror in a half-synced state where some files Drive
// owns are missing locally AND some local-only files have been
// removed. Surface the failure first; the operator can re-run
// after fixing whatever caused the download error.
if deleteLocal && downloadFailed == 0 {
// Walk the canonical absolute root, build the list of
// rel_paths, then delete via the absolute path. Both
// values come from the validated safeRoot, so kernel
// path resolution cannot redirect the delete to a file
// outside the canonical subtree.
localAbsPaths, err := drivePullWalkLocal(safeRoot)
if err != nil {
return err
}
for _, absPath := range localAbsPaths {
rel, relErr := filepath.Rel(safeRoot, absPath)
if relErr != nil {
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
failed++
continue
}
rel = filepath.ToSlash(rel)
// Consult remotePaths (every Drive entry, regardless of
// type) rather than remoteFiles (downloadable subset
// only). Otherwise an online doc / shortcut at e.g.
// "notes.docx" would leave a same-named local file
// looking orphaned and get unlinked even though Drive
// still knows about that path.
if _, ok := remotePaths[rel]; ok {
continue
}
// FileIO has no Remove(); the absolute path comes from
// walking safeRoot, which validate.SafeInputPath has
// already bounded inside cwd, so a bare os.Remove is
// acceptable here. Shortcuts cannot import internal/vfs
// directly (depguard rule shortcuts-no-vfs).
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
failed++
continue
}
items = append(items, drivePullItem{RelPath: rel, Action: "deleted_local"})
deletedLocal++
}
}
payload := map[string]interface{}{
"summary": map[string]interface{}{
"downloaded": downloaded,
"skipped": skipped,
"failed": failed,
"deleted_local": deletedLocal,
},
"items": items,
}
// Item-level failures (download error, dir/file conflict, delete
// error) must surface as a non-zero exit so AI / script callers
// don't have to reach into summary.failed to detect a partial
// sync. The same structured payload rides along in error.detail
// so forensics aren't lost. When --delete-local was skipped
// because of an earlier download failure, callers see
// deleted_local=0 plus the download failure that aborted it,
// which is what makes the partial state self-explanatory.
if failed > 0 {
msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
if deleteLocal && downloadFailed > 0 {
msg += " (--delete-local was skipped because the download pass had failures)"
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: msg,
Detail: payload,
},
}
}
runtime.Out(payload, nil)
return nil
},
}
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
}
defer resp.Body.Close()
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body); err != nil {
return common.WrapSaveErrorByCategory(err, "io")
}
return nil
}
// drivePullWalkLocal walks the canonical absolute root and returns the
// absolute paths of every regular file underneath it. The caller deletes
// some of these paths, so it is critical that they are produced by
// walking a canonical root (no symlinks in the path) — otherwise OS path
// resolution could redirect a delete to a file outside cwd. Same threat
// model as drive_status.go.
func drivePullWalkLocal(root string) ([]string, error) {
var paths []string
// FileIO has no walker today; shortcuts cannot import internal/vfs
// (depguard rule shortcuts-no-vfs). The root passed in is the
// canonical absolute path returned by validate.SafeInputPath, so
// WalkDir's default "do not follow child symlinks" policy keeps the
// traversal inside the validated subtree.
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
if walkErr != nil {
return walkErr
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
paths = append(paths, absPath)
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
}
return paths, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,717 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"path"
"path/filepath"
"sort"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
drivePushIfExistsOverwrite = "overwrite"
drivePushIfExistsSkip = "skip"
)
type drivePushItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Version string `json:"version,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"`
}
// DrivePush is a one-way, file-level mirror from a local directory onto a
// Drive folder: walks --local-dir, recursively lists --folder-token, and for
// each rel_path uploads (or overwrites) the corresponding Drive file. With
// --delete-remote --yes, any type=file entry on Drive that has no local
// counterpart is removed; online docs (docx/sheet/bitable/...), shortcuts
// and folders are never deleted, so this is "file-level" mirror — the
// command does not attempt to remove remote-only directories or close gaps
// in directory structure that exists on Drive but not locally.
//
// Only Drive entries with type=file participate in upload/overwrite/delete;
// online documents have no equivalent local binary. Sub-folders are created
// on Drive on demand via /open-apis/drive/v1/files/create_folder so the
// remote tree mirrors the local tree.
//
// The overwrite path passes the existing file_token as a form field on
// /open-apis/drive/v1/files/upload_all, mirroring the markdown +overwrite
// contract in shortcuts/markdown. The Drive backend exposing that field is
// being rolled out; until rollout completes, --if-exists defaults to "skip"
// so the safe path (do not touch existing remote files) is the default and
// callers must opt into "overwrite" explicitly.
var DrivePush = common.Shortcut{
Service: "drive",
Command: "+push",
Description: "File-level mirror of a local directory onto a Drive folder (local → Drive; remote-only directories are not removed)",
Risk: "write",
// Narrowed scopes follow the precedent set by drive +status / +pull:
// drive:drive is policy-disabled in some tenants, so this shortcut sticks
// to the smallest set the *core* path needs. space:folder:create is
// always declared because mirroring a non-flat tree calls
// /open-apis/drive/v1/files/create_folder on demand and we want the
// framework's pre-flight scope check to catch missing grants before any
// upload — otherwise a partial push could land top-level files and then
// trip on a missing folder grant for a sub-tree, leaving a half-synced
// state.
//
// space:document:delete is intentionally NOT in the default set even
// though --delete-remote needs it. The framework pre-check (runner.go
// checkShortcutScopes) runs unconditionally before Validate / dry-run,
// so declaring it here would make every plain push (and every
// --dry-run) fail for callers that only granted upload scopes.
//
// Instead, Validate runs a *conditional* pre-flight via
// runtime.EnsureScopes when both --delete-remote and --yes are on, so
// the missing grant fails the run upfront — before any upload —
// rather than landing files first and tripping on missing_scope when
// the cleanup pass tries to delete. That avoids the half-synced state
// (files uploaded, orphans never cleaned up) that the unconditional
// pre-check would otherwise prevent only by also blocking plain
// pushes.
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:upload", "space:folder:create"},
AuthTypes: []string{"user", "bot"},
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: "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"},
},
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.",
"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.",
"--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.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)")
}
// Conditional scope pre-check: when --delete-remote --yes is set, the
// run will issue DELETE /open-apis/drive/v1/files/<token> after the
// upload phase. The default Scopes list intentionally omits
// space:document:delete so plain pushes don't get blocked on a grant
// they don't need (see the Scopes block above), but at this point we
// know the run will need it — pre-flight here so a missing grant
// fails before any upload, instead of after, which would otherwise
// leave the tenant in a half-synced state (files uploaded, remote
// orphans never cleaned up). EnsureScopes is a silent no-op when no
// token / scope metadata is available, so test envs and tenants
// where the resolver doesn't expose scopes still proceed and rely on
// the API-level missing_scope error.
if runtime.Bool("delete-remote") && runtime.Bool("yes") {
if err := runtime.EnsureScopes([]string{"space:document:delete"}); err != nil {
return err
}
}
return nil
},
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.").
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"))
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
if ifExists == "" {
// Default to the safe "skip" policy: do not touch already-present
// remote files. Callers must pass --if-exists=overwrite to opt
// into the overwrite-with-version path that depends on the
// rolling-out upload_all `file_token`/`version` protocol field.
ifExists = drivePushIfExistsSkip
}
deleteRemote := runtime.Bool("delete-remote")
// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
// which closes the kernel-level escape route that filepath.Clean
// alone misses (e.g. "link/.." string-cleans to "." but the kernel
// resolves through link's target's parent). Walking the canonical
// root sidesteps that, and the matching cwd canonical lets each
// absolute walk hit be converted to a cwd-relative path that
// FileIO.Open's SafeInputPath check still accepts.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
if err != nil {
return err
}
// Two views over the same listing:
// - remoteFiles drives upload / overwrite / orphan-delete
// decisions (only type=file entries are upload candidates;
// online docs / shortcuts are intentionally never overwritten
// or deleted by --delete-remote).
// - remoteFolders is the create_folder cache: lets the upload
// path skip create_folder when an intermediate folder already
// exists, and keeps directory recreation idempotent across
// reruns.
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
for rel, entry := range entries {
switch entry.Type {
case driveTypeFile:
remoteFiles[rel] = entry
case driveTypeFolder:
remoteFolders[rel] = entry
}
}
var uploaded, skipped, failed, deletedRemote int
items := make([]drivePushItem, 0)
// uploadFailed tracks whether any folder-creation, upload or
// overwrite step failed. The --delete-remote phase only runs when
// this stays false: a partial upload that then proceeds to delete
// remote orphans would leave the tenant half-synced (files missing
// locally and now on Drive too), which is the worst-of-both-worlds
// outcome the review flagged.
uploadFailed := false
// folderCache holds rel_path → folder_token. Seeded from the remote
// listing (so we don't recreate folders that already exist) and
// extended in-place as drivePushEnsureFolder mints new ones.
folderCache := map[string]string{"": folderToken}
for relDir, entry := range remoteFolders {
folderCache[relDir] = entry.FileToken
}
// Mirror local directory structure first, so empty directories
// are not silently dropped. Pre-creating also frees the upload
// loop from doing on-demand mkdir for every file's parent chain
// (the cache makes both paths idempotent, but pre-creation keeps
// items[] in a tidy "folders, then files" shape).
for _, relDir := range localDirs {
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
// Folder already exists on Drive — nothing to do; staying
// silent (no items[] entry) avoids noise on reruns.
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()})
failed++
uploadFailed = true
continue
}
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
}
// Upload local-only and overwrite/skip already-present files in a
// stable order so output is reproducible.
localPaths := make([]string, 0, len(localFiles))
for p := range localFiles {
localPaths = append(localPaths, p)
}
sort.Strings(localPaths)
for _, rel := range localPaths {
localFile := localFiles[rel]
if entry, ok := remoteFiles[rel]; ok {
if ifExists == drivePushIfExistsSkip {
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)
if upErr != nil {
// Token contract on overwrite failure: an in-place
// overwrite preserves the file's token, so the
// existing entry.FileToken is normally still the
// authoritative pointer to the (possibly already
// rewritten) Drive file. But the protocol does not
// strictly forbid the backend from minting a new
// token, and a partial-success response can return a
// non-empty file_token alongside an error (the
// missing-version case below is the immediate
// concern: bytes hit the disk, version field
// missing, so we surface a structured error). Prefer
// the freshly returned token when one was produced,
// fall back to entry.FileToken otherwise — that way
// callers still have a usable handle to whatever
// state Drive ended up in.
failedToken := token
if failedToken == "" {
failedToken = entry.FileToken
}
items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
failed++
uploadFailed = true
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
uploaded++
continue
}
parentRel := drivePushParentRel(rel)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()})
failed++
uploadFailed = true
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
if upErr != nil {
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
failed++
uploadFailed = true
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
uploaded++
}
// Skip the delete phase entirely on any upstream failure. The orphan
// loop deletes by remote token and is unrecoverable; running it
// after a failed upload risks deleting a file the partial upload
// would have replaced on a successful re-run, leaving the tenant
// in a worse state than where we started. Surface the skipped
// delete as a hint in stderr so operators know the cleanup pass
// is pending and can re-run after fixing the upload.
if deleteRemote && uploadFailed {
fmt.Fprintf(runtime.IO().ErrOut,
"Skipping --delete-remote: %d earlier failure(s) — re-run after resolving them.\n",
failed)
}
if deleteRemote && !uploadFailed {
// Stable iteration order so failures (and tests) are deterministic.
remoteRelPaths := make([]string, 0, len(remoteFiles))
for p := range remoteFiles {
remoteRelPaths = append(remoteRelPaths, p)
}
sort.Strings(remoteRelPaths)
for _, rel := range remoteRelPaths {
if _, ok := localFiles[rel]; ok {
continue
}
entry := remoteFiles[rel]
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
failed++
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
deletedRemote++
}
}
runtime.Out(map[string]interface{}{
"summary": map[string]interface{}{
"uploaded": uploaded,
"skipped": skipped,
"failed": failed,
"deleted_remote": deletedRemote,
},
"items": items,
}, nil)
// Bump the exit code on any item-level failure (upload, overwrite,
// folder, or delete) so callers / scripts / agents can react. The
// summary + items[] envelope was just written to stdout via Out(),
// so ErrBare here only affects the exit code — the structured
// per-item context is still in the stdout JSON.
if failed > 0 {
return output.ErrBare(output.ExitAPI)
}
return nil
},
}
// drivePushLocalFile records what we need to upload a local regular file:
// a rel_path used for output and Drive layout, the cwd-relative path that
// FileIO.Open accepts, the file size (drives single/multipart selection),
// and the basename used as Drive's file_name.
type drivePushLocalFile struct {
RelPath string
OpenPath string
FileName string
Size int64
}
// drivePushWalkLocal walks the canonical absolute root produced by
// SafeInputPath. Same threat model as +pull/+status: the validated root
// is not a symlink itself, and WalkDir's default policy (do not follow
// child symlinks) keeps the traversal inside that canonical subtree, so
// the OpenPath we hand to FileIO.Open stays inside cwd.
//
// Returns two views:
// - files: rel_path → file metadata; drives the upload/skip/overwrite loop.
// - dirs: every non-root directory rel_path encountered. Used to mirror
// empty directories (which would otherwise be silently dropped because
// the upload loop only iterates files); non-empty directories appear
// here too but are harmless because drivePushEnsureFolder is cached.
func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFile, []string, error) {
files := make(map[string]drivePushLocalFile)
dirsSet := make(map[string]struct{})
// FileIO has no walker today and shortcuts can't import internal/vfs
// (depguard rule shortcuts-no-vfs). The walk root is the canonical
// absolute path returned by validate.SafeInputPath, so it is no
// longer a symlink itself, and WalkDir's default child-symlink
// policy keeps the traversal inside the validated subtree.
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, absPath)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
if d.IsDir() {
// Skip the root itself ("."): that is --folder-token, already
// the parent we mirror into, not a sub-folder we need to
// create.
if relSlash != "." {
dirsSet[relSlash] = struct{}{}
}
return nil
}
if !d.Type().IsRegular() {
return nil
}
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
files[relSlash] = drivePushLocalFile{
RelPath: relSlash,
OpenPath: relToCwd,
FileName: filepath.Base(rel),
Size: info.Size(),
}
return nil
})
if err != nil {
return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
}
dirs := make([]string, 0, len(dirsSet))
for d := range dirsSet {
dirs = append(dirs, d)
}
// Shallow-first ordering ensures parents are created before children;
// drivePushEnsureFolder also handles parent recursion on its own, but
// emitting items[] in shallow-first order matches what users expect.
sort.Slice(dirs, func(i, j int) bool {
di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/")
if di != dj {
return di < dj
}
return dirs[i] < dirs[j]
})
return files, dirs, nil
}
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
// folder identified by rootFolderToken) exists on Drive, creating any
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the
// token of the deepest folder, suitable as parent_node for the upload.
//
// folderCache is shared with the caller so each segment is only created
// once per push, and so subsequent uploads under the same sub-tree reuse
// the freshly minted folder token without an extra round trip.
func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relDir string, folderCache map[string]string) (string, error) {
if token, ok := folderCache[relDir]; ok {
return token, nil
}
parentRel, name := drivePushSplitRel(relDir)
parentToken, err := drivePushEnsureFolder(ctx, runtime, rootFolderToken, parentRel, folderCache)
if err != nil {
return "", err
}
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/files/create_folder",
nil,
map[string]interface{}{
"name": name,
"folder_token": parentToken,
},
)
if err != nil {
return "", err
}
token := common.GetString(data, "token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir)
}
folderCache[relDir] = token
return token, nil
}
// 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
// expected to carry a non-empty `version`, which is propagated to the
// caller for the items[].version field. When existingToken is empty, this
// is a fresh upload under parentToken.
//
// Files larger than common.MaxDriveMediaUploadSinglePartSize fall back to
// the three-step prepare/part/finish flow, which mirrors drive +upload's
// existing multipart logic.
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
// Multipart finish does not return version on the existing
// /open-apis/drive/v1/files/upload_finish contract; surface an
// empty version in that case rather than fabricating one. The
// markdown +overwrite path has the same gap and is tracked for a
// follow-up once the multipart endpoint exposes the field.
return token, "", err
}
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
}
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
f, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {
return "", "", common.WrapInputStatError(err)
}
defer f.Close()
fd := larkcore.NewFormdata()
fd.AddField("file_name", file.FileName)
fd.AddField("parent_type", driveUploadParentTypeExplorer)
fd.AddField("parent_node", parentToken)
fd.AddField("size", fmt.Sprintf("%d", file.Size))
if existingToken != "" {
// Overwrite mode: the backend interprets a non-empty file_token on
// upload_all as "replace this file's content and bump its version",
// matching the markdown +overwrite contract.
fd.AddField("file_token", existingToken)
}
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return "", "", err
}
return "", "", 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)
}
// Extract the token before the larkCode check: the backend can produce
// a partial-success response (code != 0 alongside a non-empty
// data.file_token) where bytes have already landed under that token.
// Returning "" here would force the caller to fall back to
// entry.FileToken and silently lose the token Drive actually used,
// defeating the overwrite-error token-stability handling in Execute.
data, _ := result["data"].(map[string]interface{})
token := common.GetString(data, "file_token")
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
}
if token == "" {
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
}
version := common.GetString(data, "version")
if version == "" {
// Some backends return the version under data_version; accept either
// per the markdown +overwrite contract.
version = common.GetString(data, "data_version")
}
if existingToken != "" && version == "" {
// The protocol guarantees a non-empty version on overwrite. If the
// deployed backend hasn't shipped the field yet we surface the gap
// rather than report a phantom success — callers can downgrade to
// --if-exists=skip in the meantime.
return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
}
return token, version, nil
}
func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, error) {
prepareBody := map[string]interface{}{
"file_name": file.FileName,
"parent_type": driveUploadParentTypeExplorer,
"parent_node": parentToken,
"size": file.Size,
}
if existingToken != "" {
prepareBody["file_token"] = existingToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
}
uploadID := common.GetString(prepareResult, "upload_id")
blockSize := int64(common.GetFloat(prepareResult, "block_size"))
blockNum := int(common.GetFloat(prepareResult, "block_num"))
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return "", output.Errorf(output.ExitAPI, "api_error",
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload: %s, block size %s, %d block(s)\n",
common.FormatSize(file.Size), common.FormatSize(blockSize), blockNum)
// Open the local file ONCE for the whole multipart loop. fileio.File
// implements io.ReaderAt, so each block is a fresh
// io.NewSectionReader over a shared fd — no need to reopen N times
// (which is what drive +upload's existing multipart helper does and
// what the original drive_push copy inherited; that pattern wastes
// one Open + Close + path-validation per block).
partFile, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer partFile.Close()
for seq := 0; seq < blockNum; seq++ {
offset := int64(seq) * blockSize
partSize := blockSize
if remaining := file.Size - offset; partSize > remaining {
partSize = remaining
}
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", partSize))
fd.AddFile("file", io.NewSectionReader(partFile, offset, partSize))
apiResp, doErr := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if doErr != nil {
var exitErr *output.ExitError
if errors.As(doErr, &exitErr) {
return "", doErr
}
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr)
}
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)
}
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"])
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
return "", err
}
token := common.GetString(finishResult, "file_token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
}
return token, nil
}
// drivePushDeleteFile deletes a single Drive file (type=file). Folders are
// never reached here because --delete-remote only iterates the type=file
// subset of the remote listing.
func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error {
_, err := runtime.CallAPI(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
map[string]interface{}{"type": driveTypeFile},
nil,
)
return err
}
// drivePushParentRel returns the parent rel_path of rel ("" when the file
// lives at the root). The local walker emits forward-slash rel_paths so
// path.Dir is the right primitive here, not filepath.Dir.
func drivePushParentRel(rel string) string {
dir := path.Dir(rel)
if dir == "." || dir == "/" {
return ""
}
return dir
}
// drivePushSplitRel splits a non-empty rel into (parent, basename), both
// using forward slashes.
func drivePushSplitRel(rel string) (string, string) {
idx := strings.LastIndex(rel, "/")
if idx < 0 {
return "", rel
}
return rel[:idx], rel[idx+1:]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"path/filepath"
"sort"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveStatusEntry struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
}
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
//
// Only Drive entries with type=file are compared; online docs (docx, sheet,
// bitable, mindnote, slides) and shortcuts are skipped because there is no
// equivalent local binary to hash against.
//
// SafeInputPath (applied by runtime.FileIO()) rejects absolute paths and any
// 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"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "Drive folder token", Required: true},
},
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.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
// Path safety (absolute paths, traversal, symlink escape) is enforced
// upfront by the framework helper so the error message references the
// correct flag name; FileIO().Stat below would do the same check, but
// surface --file in its hint.
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
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"))
// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
// which closes the kernel-level escape route that filepath.Clean
// alone misses: e.g. "link/.." string-cleans to "." but the kernel
// resolves through link's target's parent, so a raw walk on the
// user-supplied string can land outside cwd. Walking the canonical
// root sidesteps that — and the matching cwd canonical lets each
// absolute walk hit be converted to a cwd-relative path that
// FileIO.Open's SafeInputPath check still accepts.
//
// Validate already ran SafeLocalFlagPath (with the proper flag
// name in the error message), so a failure here is unexpected and
// only possible under a Validate↔Execute race.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
if err != nil {
return err
}
// +status only diffs binary content, so collapse the unified
// listing to type=file. Online docs / shortcuts have no
// 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))
for rel, entry := range entries {
if entry.Type == driveTypeFile {
remoteFiles[rel] = entry.FileToken
}
}
paths := mergeStatusPaths(localHashes, remoteFiles)
var newLocal, newRemote, modified, unchanged []driveStatusEntry
for _, relPath := range paths {
localHash, hasLocal := localHashes[relPath]
remoteToken, hasRemote := remoteFiles[relPath]
switch {
case hasLocal && !hasRemote:
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
case !hasLocal && hasRemote:
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
default:
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
if err != nil {
return err
}
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
if localHash == remoteHash {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
}
}
runtime.Out(map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
}, nil)
return nil
},
}
// walkLocalForStatus walks the canonical absolute root produced by
// SafeInputPath. Using the canonical root keeps the kernel from
// following any symlink hidden inside the user-supplied --local-dir
// (e.g. "link/..", which filepath.Clean shrinks to "." but which OS
// path resolution would resolve through the symlink target). For each
// 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)
// 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
// WalkDir's default policy (do not follow child symlinks) keeps the
// traversal inside that canonical subtree.
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
if walkErr != nil {
return walkErr
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
rel, err := filepath.Rel(root, absPath)
if err != nil {
return err
}
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
if err != nil {
return err
}
sum, err := hashLocalForStatus(runtime, relToCwd)
if err != nil {
return err
}
files[filepath.ToSlash(rel)] = sum
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
}
return files, nil
}
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (string, error) {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
}
defer resp.Body.Close()
h := sha256.New()
if _, err := io.Copy(h, resp.Body); err != nil {
return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func mergeStatusPaths(local, remote map[string]string) []string {
seen := make(map[string]struct{}, len(local)+len(remote))
for p := range local {
seen[p] = struct{}{}
}
for p := range remote {
seen[p] = struct{}{}
}
out := make([]string, 0, len(seen))
for p := range seen {
out = append(out, p)
}
sort.Strings(out)
return out
}
func emptyIfNil(s []driveStatusEntry) []driveStatusEntry {
if s == nil {
return []driveStatusEntry{}
}
return s
}

View File

@@ -0,0 +1,498 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
// against a real walk of the temp dir and a mocked Drive listing.
func TestDriveStatusCategorizesByHash(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
// Local layout:
// local/a.txt — also on remote with different content → modified
// local/b.txt — only local → new_local
// local/sub/c.txt — also on remote with same content → unchanged
// Remote-only:
// d.txt → new_remote
if err := os.MkdirAll("local/sub", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("aaa"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
if err := os.WriteFile("local/b.txt", []byte("bbb"), 0o644); err != nil {
t.Fatalf("WriteFile b.txt: %v", err)
}
if err := os.WriteFile("local/sub/c.txt", []byte("ccc"), 0o644); err != nil {
t.Fatalf("WriteFile sub/c.txt: %v", err)
}
// Root folder list — order matters: stubs match in registration order.
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"},
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"},
// noise: an online doc and a shortcut should be ignored
map[string]interface{}{"token": "tok_doc", "name": "ignored.docx", "type": "docx"},
map[string]interface{}{"token": "tok_sc", "name": "ignored.lnk", "type": "shortcut"},
},
"has_more": false,
},
},
})
// Subfolder list
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"},
},
"has_more": false,
},
},
})
// Download a.txt: remote content differs from local "aaa" → modified.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: 200,
Body: []byte("AAA"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
// Download c.txt: remote content matches local "ccc" → unchanged.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_c/download",
Status: 200,
Body: []byte("ccc"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
checks := []struct {
bucket string
path string
token string
}{
{"new_local", "b.txt", ""},
{"new_remote", "d.txt", "tok_d"},
{"modified", "a.txt", "tok_a"},
{"unchanged", "sub/c.txt", "tok_c"},
}
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)
}
}
if strings.Contains(out, "ignored.docx") || strings.Contains(out, "ignored.lnk") {
t.Errorf("output should skip docx/shortcut entries\noutput: %s", out)
}
reg.Verify(t)
}
// 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`
// (what the shared helper also accepts). If the shortcut had hard-coded
// either field name, one of the two pages' files would be silently dropped
// from the comparison and would land in the wrong bucket. Stub order is
// significant: httpmock matches in registration order, and both stubs key on
// the GET .../files URL — they pop in turn, so page 1's response (with the
// continuation token) must be registered before page 2's terminator.
func TestDriveStatusPaginatesRemoteListing(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)
}
// Page 1: returns one file plus a continuation token via
// next_page_token (the field Drive currently emits).
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_p1", "name": "page1.txt", "type": "file"},
},
"has_more": true,
"next_page_token": "cursor-page-2",
},
},
})
// Page 2: returns the second file with has_more=false. This stub uses
// page_token (the alternate spelling) to lock in that the shared
// PaginationMeta helper accepts BOTH field names.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_p2", "name": "page2.txt", "type": "file"},
},
"has_more": false,
"page_token": "",
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
// Both pages contributed to new_remote (local is empty).
for _, want := range []string{
`"rel_path": "page1.txt"`,
`"file_token": "tok_p1"`,
`"rel_path": "page2.txt"`,
`"file_token": "tok_p2"`,
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q (a page must have been silently dropped)\noutput: %s", want, out)
}
}
reg.Verify(t)
}
func TestDriveStatusRejectsMissingLocalDir(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "does-not-exist",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing local dir, got nil")
}
}
func TestDriveStatusRejectsLocalFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("not-a-dir.txt", []byte("x"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "not-a-dir.txt",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error when --local-dir is a file, got nil")
}
if !strings.Contains(err.Error(), "not a directory") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestDriveStatusRejectsAbsoluteLocalDir(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "/etc",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for absolute --local-dir, got nil")
}
}
// TestDriveStatusRejectsEmptyFolderToken covers the Validate-stage required
// check that runs before ResourceName: an empty --folder-token must surface
// a structured FlagError referencing the flag name.
func TestDriveStatusRejectsEmptyFolderToken(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)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for empty --folder-token, got nil")
}
if !strings.Contains(err.Error(), "--folder-token") {
t.Fatalf("error must reference --folder-token, got: %v", err)
}
}
// TestDriveStatusDoesNotEscapeViaSymlinkParentRef is the regression for the
// "link/.." escape: filepath.Clean string-shrinks "link/.." to ".", so a
// raw walk on the user-supplied input can land on the kernel-resolved
// path through link's target's parent — outside cwd. The fix is to walk
// SafeInputPath's canonical absolute root instead of the raw input.
//
// Setup: an "escape" sibling directory contains a sentinel file; cwd
// contains a "link" symlink pointing into that escape directory.
// Calling +status with --local-dir "link/.." must not surface the
// sentinel — the walk must stay inside cwd.
func TestDriveStatusDoesNotEscapeViaSymlinkParentRef(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
// Sentinel lives outside cwd; the agent must never see it.
escapeDir := t.TempDir()
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
t.Fatalf("WriteFile secret: %v", err)
}
// cwd has a symlink that points into the sentinel's parent.
cwdDir := t.TempDir()
withDriveWorkingDir(t, cwdDir)
if err := os.Symlink(escapeDir, filepath.Join(cwdDir, "link")); err != nil {
t.Fatalf("Symlink: %v", err)
}
// A normal file inside cwd just to make the walk non-trivial.
if err := os.WriteFile(filepath.Join(cwdDir, "ok.txt"), []byte("ok"), 0o644); err != nil {
t.Fatalf("WriteFile ok: %v", err)
}
// Empty remote folder so any path that surfaces in the output
// must have come from the local walk.
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{}{},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "link/..",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
t.Fatalf("walk escaped via link/..: secret.txt leaked into output\noutput:\n%s", out)
}
// ok.txt is in cwd and must classify as new_local (no remote stub for it).
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
t.Fatalf("expected ok.txt in new_local, got:\n%s", out)
}
}
// TestDriveStatusSkipsSymlinkInsideRoot pins down WalkDir's default policy
// for symlinks discovered as child entries: they are reported with a
// non-regular file mode and the callback skips them, so a symlink inside
// the validated root pointing into an out-of-tree directory cannot leak
// the target's contents.
func TestDriveStatusSkipsSymlinkInsideRoot(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
// Sentinel sits outside cwd; a child symlink inside the walked root
// points there. If the walker followed child symlinks (it must not),
// the sentinel's name would surface in new_local.
escapeDir := t.TempDir()
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
t.Fatalf("WriteFile secret: %v", err)
}
cwdDir := t.TempDir()
withDriveWorkingDir(t, cwdDir)
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "ok.txt"), []byte("ok"), 0o644); err != nil {
t.Fatalf("WriteFile ok: %v", err)
}
// Child-of-root symlink that resolves out of the validated subtree.
if err := os.Symlink(escapeDir, filepath.Join("local", "sub", "escape")); err != nil {
t.Fatalf("Symlink: %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{}{},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
t.Fatalf("walk followed child symlink and leaked sentinel:\n%s", out)
}
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
t.Fatalf("expected ok.txt in new_local; got:\n%s", out)
}
}
// TestDriveStatusSurvivesCircularSymlinkInsideRoot makes sure WalkDir
// terminates even when a child symlink points back at one of its
// ancestors. WalkDir's default policy already declines to follow child
// symlinks; this test pins that contract for our caller.
func TestDriveStatusSurvivesCircularSymlinkInsideRoot(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
cwdDir := t.TempDir()
withDriveWorkingDir(t, cwdDir)
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "sub", "real.txt"), []byte("real"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// loop symlink: cwd/local/sub/loop -> cwd/local (an ancestor).
loopTarget, err := filepath.Abs(filepath.Join("local"))
if err != nil {
t.Fatalf("Abs: %v", err)
}
if err := os.Symlink(loopTarget, filepath.Join("local", "sub", "loop")); err != nil {
t.Fatalf("Symlink: %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{}{},
"has_more": false,
},
},
})
// If WalkDir followed the loop, this test would never finish; the
// test runner's per-test timeout would surface that as a failure.
err = mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
if !strings.Contains(stdout.String(), `"rel_path": "sub/real.txt"`) {
t.Fatalf("expected sub/real.txt in new_local; got:\n%s", stdout.String())
}
}
// TestDriveStatusRejectsMalformedFolderToken covers the ResourceName format
// guard: a token with control characters (newline) must be rejected before
// any API call is made.
func TestDriveStatusRejectsMalformedFolderToken(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)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "tok\nwithnewline",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for malformed --folder-token, got nil")
}
if !strings.Contains(err.Error(), "--folder-token") {
t.Fatalf("error must reference --folder-token, got: %v", err)
}
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveListRemotePageSize = 200
driveTypeFile = "file"
driveTypeFolder = "folder"
)
// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It
// carries enough metadata for every shortcut that consumes the listing
// to build its own per-shortcut view by filtering on Type.
type driveRemoteEntry struct {
// FileToken is the Drive token for this entry. For type=folder this
// is the folder_token; for everything else it is the file_token.
FileToken string
// Type is the Drive entry kind verbatim from the API:
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
// "mindnote" | "slides" | "shortcut" | …
Type string
// RelPath is the entry's path relative to the listing root. Encoded
// with "/" separators on every platform so it matches the rel_paths
// produced by the shortcuts' local walkers.
RelPath string
}
// listRemoteFolder recursively lists folderToken under relBase and
// returns one entry per Drive item, keyed by rel_path. Subfolders are
// descended into and the folder's own entry is also recorded — callers
// can reason about "this rel_path is occupied by a folder" without
// re-listing.
//
// This is the shared backbone for the three sync-disk shortcuts. None
// of them need every field at every call site, so each one filters
// on Type:
//
// - +status (drive_status.go) keeps Type=="file" and uses FileToken
// to drive content-hash diffs against the local tree.
// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the
// download set, and the full key set (every rel_path) as the
// guard for --delete-local.
// - +push (drive_push.go) keeps Type=="file" + FileToken for upload /
// overwrite / orphan-delete decisions, and Type=="folder" + FileToken
// for the create_folder cache.
//
// Pagination uses common.PaginationMeta, which accepts both
// page_token and next_page_token — the Drive list endpoint has
// historically returned the latter, but the helper future-proofs
// against a backend rename.
func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) {
out := make(map[string]driveRemoteEntry)
pageToken := ""
for {
params := map[string]interface{}{
"folder_token": folderToken,
"page_size": fmt.Sprint(driveListRemotePageSize),
}
if pageToken != "" {
params["page_token"] = pageToken
}
result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
if err != nil {
return nil, err
}
rawFiles, _ := result["files"].([]interface{})
for _, item := range rawFiles {
f, ok := item.(map[string]interface{})
if !ok {
continue
}
fType := common.GetString(f, "type")
fName := common.GetString(f, "name")
fToken := common.GetString(f, "token")
if fName == "" || fToken == "" {
continue
}
rel := joinRelDrive(relBase, fName)
out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel}
if fType == driveTypeFolder {
sub, err := listRemoteFolder(ctx, runtime, fToken, rel)
if err != nil {
return nil, err
}
for k, v := range sub {
out[k] = v
}
}
}
hasMore, nextToken := common.PaginationMeta(result)
if !hasMore || nextToken == "" {
break
}
pageToken = nextToken
}
return out, nil
}
// joinRelDrive joins a rel_path base with an entry name using "/".
// Empty base means the entry sits at the listing root. Mirrors the
// behavior the per-shortcut helpers used to ship and keeps rel_paths
// stable across +status / +pull / +push.
func joinRelDrive(base, name string) string {
if base == "" {
return name
}
return base + "/" + name
}

View File

@@ -18,6 +18,9 @@ func Shortcuts() []common.Shortcut {
DriveImport,
DriveMove,
DriveDelete,
DriveStatus,
DrivePush,
DrivePull,
DriveTaskResult,
DriveApplyPermission,
DriveSearch,

View File

@@ -21,6 +21,9 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+import",
"+move",
"+delete",
"+status",
"+push",
"+pull",
"+task_result",
"+apply-permission",
"+search",

View File

@@ -0,0 +1,530 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"path"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize
const markdownEmptyContentError = "empty markdown content is not supported; cannot create or overwrite an empty file"
type markdownUploadSpec struct {
FileToken string
FileName string
FolderToken string
FilePath string
Content string
ContentSet bool
FileSet bool
}
type markdownUploadResult struct {
FileToken string
Version string
}
type markdownMultipartSession struct {
UploadID string
BlockSize int64
BlockNum int
}
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
switch {
case spec.ContentSet && spec.FileSet:
return common.FlagErrorf("--content and --file are mutually exclusive")
case !spec.ContentSet && !spec.FileSet:
return common.FlagErrorf("specify exactly one of --content or --file")
}
if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" {
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
if requireName && spec.ContentSet {
if strings.TrimSpace(spec.FileName) == "" {
return common.FlagErrorf("--name is required when using --content")
}
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
return err
}
}
if spec.FileSet {
if strings.TrimSpace(spec.FilePath) == "" {
return common.FlagErrorf("--file cannot be empty")
}
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
return output.ErrValidation("unsafe file path: %s", err)
}
if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil {
return err
}
}
if spec.FileName != "" {
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
return err
}
}
return nil
}
func validateMarkdownFileName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return common.FlagErrorf("%s cannot be empty", flagName)
}
if !strings.HasSuffix(strings.ToLower(trimmed), ".md") {
return common.FlagErrorf("%s must end with .md", flagName)
}
return nil
}
func finalMarkdownFileName(spec markdownUploadSpec) string {
if strings.TrimSpace(spec.FileName) != "" {
return strings.TrimSpace(spec.FileName)
}
if strings.TrimSpace(spec.FilePath) == "" {
return ""
}
return filepath.Base(spec.FilePath)
}
func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) (int64, error) {
var size int64
if spec.ContentSet {
size = int64(len(spec.Content))
} else {
if strings.TrimSpace(spec.FilePath) == "" {
return 0, common.FlagErrorf("--file cannot be empty")
}
info, err := runtime.FileIO().Stat(spec.FilePath)
if err != nil {
return 0, common.WrapInputStatError(err)
}
size = info.Size()
}
if size == 0 {
return 0, output.ErrValidation("%s", markdownEmptyContentError)
}
return size, nil
}
func markdownDryRunFileField(spec markdownUploadSpec) string {
if spec.FilePath != "" {
return "@" + spec.FilePath
}
return "<markdown content>"
}
func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
fileName := finalMarkdownFileName(spec)
if !multipart {
body := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"size": fileSize,
"file": markdownDryRunFileField(spec),
}
if spec.FileToken != "" {
body["file_token"] = spec.FileToken
}
desc := "multipart/form-data upload"
if spec.FileToken != "" {
desc = "multipart/form-data overwrite upload"
}
return common.NewDryRunAPI().
Desc(desc).
POST("/open-apis/drive/v1/files/upload_all").
Body(body)
}
prepareBody := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"size": fileSize,
}
if spec.FileToken != "" {
prepareBody["file_token"] = spec.FileToken
}
desc := "3-step multipart upload"
if spec.FileToken != "" {
desc = "3-step multipart overwrite upload"
}
return common.NewDryRunAPI().
Desc(desc).
POST("/open-apis/drive/v1/files/upload_prepare").
Desc("[1] Initialize multipart upload").
Body(prepareBody).
POST("/open-apis/drive/v1/files/upload_part").
Desc("[2] Upload file parts (repeated)").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/files/upload_finish").
Desc("[3] Finalize upload and get file_token/version").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
}
func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
fileName := strings.TrimSpace(spec.FileName)
if fileName == "" && spec.FileSet {
fileName = finalMarkdownFileName(spec)
}
if fileName != "" {
spec.FileName = fileName
return markdownUploadDryRun(spec, fileSize, multipart)
}
dry := common.NewDryRunAPI().Desc("Fetch the existing file name, then overwrite the file content")
dry.POST("/open-apis/drive/v1/metas/batch_query").
Desc("[1] Read current file metadata to preserve the existing file name").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": spec.FileToken,
"doc_type": "file",
},
},
})
spec.FileName = "<existing_remote_name_or_" + spec.FileToken + ".md>"
if !multipart {
dry.POST("/open-apis/drive/v1/files/upload_all").
Desc("[2] Overwrite file contents with multipart/form-data upload").
Body(map[string]interface{}{
"file_name": spec.FileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"size": fileSize,
"file": markdownDryRunFileField(spec),
"file_token": spec.FileToken,
})
return dry
}
dry.POST("/open-apis/drive/v1/files/upload_prepare").
Desc("[2] Initialize multipart overwrite upload").
Body(map[string]interface{}{
"file_name": spec.FileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"size": fileSize,
"file_token": spec.FileToken,
}).
POST("/open-apis/drive/v1/files/upload_part").
Desc("[3] Upload file parts (repeated)").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/files/upload_finish").
Desc("[4] Finalize upload and get file_token/version").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return dry
}
func uploadMarkdownContent(runtime *common.RuntimeContext, spec markdownUploadSpec, payload []byte) (markdownUploadResult, error) {
fileName := finalMarkdownFileName(spec)
fileSize := int64(len(payload))
if fileSize > markdownSinglePartSizeLimit {
return uploadMarkdownFileMultipart(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
}
return uploadMarkdownFileAll(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
}
func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUploadSpec, fileSize int64) (markdownUploadResult, error) {
fileName := finalMarkdownFileName(spec)
f, err := runtime.FileIO().Open(spec.FilePath)
if err != nil {
return markdownUploadResult{}, common.WrapInputStatError(err)
}
defer f.Close()
if fileSize > markdownSinglePartSizeLimit {
return uploadMarkdownFileMultipart(runtime, spec, f, fileName, fileSize)
}
return uploadMarkdownFileAll(runtime, spec, f, fileName, fileSize)
}
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", "explorer")
fd.AddField("parent_node", spec.FolderToken)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if spec.FileToken != "" {
fd.AddField("file_token", spec.FileToken)
}
fd.AddFile("file", fileReader)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return markdownUploadResult{}, err
}
return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err)
}
data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed")
if err != nil {
return markdownUploadResult{}, err
}
return parseMarkdownUploadResult(data, spec.FileToken != "")
}
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
prepareBody := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"size": fileSize,
}
if spec.FileToken != "" {
prepareBody["file_token"] = spec.FileToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return markdownUploadResult{}, err
}
session, err := parseMarkdownMultipartSession(prepareResult)
if err != nil {
return markdownUploadResult{}, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize))
if err := uploadMarkdownMultipartParts(runtime, fileReader, fileSize, session); err != nil {
return markdownUploadResult{}, err
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": session.UploadID,
"block_num": session.BlockNum,
})
if err != nil {
return markdownUploadResult{}, err
}
return parseMarkdownUploadResult(finishResult, spec.FileToken != "")
}
func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) {
session := markdownMultipartSession{
UploadID: common.GetString(data, "upload_id"),
BlockSize: int64(common.GetFloat(data, "block_size")),
BlockNum: int(common.GetFloat(data, "block_num")),
}
if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 {
return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error",
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
session.UploadID, session.BlockSize, session.BlockNum)
}
return session, nil
}
func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.Reader, payloadSize int64, session markdownMultipartSession) error {
expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize)
if session.BlockNum != expectedBlocks {
return output.Errorf(
output.ExitAPI,
"api_error",
"upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d",
session.BlockSize,
session.BlockNum,
expectedBlocks,
payloadSize,
)
}
maxInt := int64(^uint(0) >> 1)
if session.BlockSize > maxInt {
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(session.BlockSize))
remaining := payloadSize
for seq := 0; seq < session.BlockNum; seq++ {
chunkSize := session.BlockSize
if remaining > 0 && chunkSize > remaining {
chunkSize = remaining
}
n, readErr := io.ReadFull(fileReader, buffer[:int(chunkSize)])
if readErr != nil {
return output.ErrValidation("cannot read file: %s", readErr)
}
fd := larkcore.NewFormdata()
fd.AddField("upload_id", session.UploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", n))
fd.AddFile("file", bytes.NewReader(buffer[:n]))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err)
}
if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, common.FormatSize(int64(n)))
remaining -= int64(n)
}
if remaining != 0 {
return output.Errorf(
output.ExitAPI,
"api_error",
"upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks",
remaining,
session.BlockNum,
)
}
return nil
}
func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool) (markdownUploadResult, error) {
result := markdownUploadResult{
FileToken: common.GetString(data, "file_token"),
Version: common.GetString(data, "version"),
}
if result.Version == "" {
result.Version = common.GetString(data, "data_version")
}
if result.FileToken == "" {
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
}
if requireVersion && result.Version == "" {
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned")
}
return result, nil
}
func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": fileToken,
"doc_type": "file",
},
},
},
)
if err != nil {
return "", err
}
metas := common.GetSlice(data, "metas")
if len(metas) == 0 {
return "", nil
}
meta, _ := metas[0].(map[string]interface{})
return common.GetString(meta, "title"), nil
}
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
version := common.GetString(data, "version")
if version == "" {
version = common.GetString(data, "data_version")
}
if version != "" {
fmt.Fprintf(w, "version: %s\n", version)
}
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
if grant := common.GetMap(data, "permission_grant"); grant != nil {
fmt.Fprintf(w, "permission_grant.status: %s\n", common.GetString(grant, "status"))
fmt.Fprintf(w, "permission_grant.perm: %s\n", common.GetString(grant, "perm"))
}
}
func prettyPrintMarkdownSavedFile(w io.Writer, data map[string]interface{}) {
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
fmt.Fprintf(w, "saved_path: %s\n", common.GetString(data, "saved_path"))
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
}
func prettyPrintMarkdownContent(w io.Writer, data map[string]interface{}) {
fmt.Fprint(w, common.GetString(data, "content"))
}
func fileNameFromDownloadHeader(header http.Header, fallback string) string {
name := fallback
if header != nil {
if headerName := larkcore.FileNameByHeader(header); strings.TrimSpace(headerName) != "" {
name = headerName
}
}
name = strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
name = path.Base(name)
if name == "" || name == "." || name == ".." {
return fallback
}
return name
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var MarkdownCreate = common.Shortcut{
Service: "markdown",
Command: "+create",
Description: "Create a Markdown file in Drive",
Risk: "write",
Scopes: []string{"drive:file:upload"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "folder-token", Desc: "target Drive folder token (default: root folder)"},
{Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"},
{Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}},
{Name: "file", Desc: "local .md file path"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownSpec(runtime, markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}, true)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
return err
}
var result markdownUploadResult
if spec.FileSet {
result, err = uploadMarkdownLocalFile(runtime, spec, fileSize)
} else {
result, err = uploadMarkdownContent(runtime, spec, []byte(spec.Content))
}
if err != nil {
return err
}
out := map[string]interface{}{
"file_token": result.FileToken,
"file_name": finalMarkdownFileName(spec),
"size_bytes": fileSize,
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
out["permission_grant"] = grant
}
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownWrite(w, out)
})
return nil
},
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var MarkdownFetch = common.Shortcut{
Service: "markdown",
Command: "+fetch",
Description: "Fetch a Markdown file from Drive",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "Markdown file token", Required: true},
{Name: "output", Desc: "local save path or directory; omit to return content directly"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing local output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
outputPath := strings.TrimSpace(runtime.Str("output"))
if outputPath == "" {
return nil
}
if _, err := validate.SafeOutputPath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := common.NewDryRunAPI().
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
GET("/open-apis/drive/v1/files/:file_token/download").
Set("file_token", runtime.Str("file-token"))
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
dry.Set("output", outputPath)
} else {
dry.Set("output", "<stdout>")
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
outputPath := strings.TrimSpace(runtime.Str("output"))
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
}
defer resp.Body.Close()
fileName := fileNameFromDownloadHeader(resp.Header, fileToken+".md")
if outputPath == "" {
payload, err := io.ReadAll(resp.Body)
if err != nil {
return output.ErrNetwork("download failed: %s", err)
}
out := map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"content": string(payload),
"size_bytes": len(payload),
}
runtime.OutFormatRaw(out, nil, func(w io.Writer) {
prettyPrintMarkdownContent(w, out)
})
return nil
}
if markdownFetchOutputIsDirectory(runtime, outputPath) {
outputPath = filepath.Join(outputPath, fileName)
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("overwrite") {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
if savedPath == "" {
savedPath = outputPath
}
out := map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"saved_path": savedPath,
"size_bytes": result.Size(),
}
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownSavedFile(w, out)
})
return nil
},
}
func markdownFetchOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
return true
}
info, err := runtime.FileIO().Stat(outputPath)
return err == nil && info.IsDir()
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var MarkdownOverwrite = common.Shortcut{
Service: "markdown",
Command: "+overwrite",
Description: "Overwrite an existing Markdown file in Drive",
Risk: "write",
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "target Markdown file token", Required: true},
{Name: "name", Desc: "optional file name with .md suffix; overrides the existing/local file name"},
{Name: "content", Desc: "new Markdown content", Input: []string{common.File, common.Stdin}},
{Name: "file", Desc: "local .md file path"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
return validateMarkdownSpec(runtime, markdownUploadSpec{
FileToken: fileToken,
FileName: strings.TrimSpace(runtime.Str("name")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}, false)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := markdownUploadSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FileName: strings.TrimSpace(runtime.Str("name")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return markdownOverwriteDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
spec := markdownUploadSpec{
FileToken: fileToken,
FileName: strings.TrimSpace(runtime.Str("name")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
fileSize, err := markdownSourceSize(runtime, spec)
if err != nil {
return err
}
fileName := strings.TrimSpace(spec.FileName)
if fileName == "" && spec.FileSet {
fileName = filepath.Base(spec.FilePath)
}
if fileName == "" {
remoteName, err := fetchMarkdownFileName(runtime, fileToken)
if err != nil {
return err
}
fileName = strings.TrimSpace(remoteName)
}
if fileName == "" {
fileName = fileToken + ".md"
}
spec.FileName = fileName
var result markdownUploadResult
if spec.FileSet {
result, err = uploadMarkdownLocalFile(runtime, spec, fileSize)
} else {
result, err = uploadMarkdownContent(runtime, spec, []byte(spec.Content))
}
if err != nil {
return err
}
out := map[string]interface{}{
"file_token": result.FileToken,
"file_name": fileName,
"version": result.Version,
"size_bytes": fileSize,
}
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownWrite(w, out)
})
return nil
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all markdown shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MarkdownCreate,
MarkdownFetch,
MarkdownOverwrite,
}
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
minutesUploadSupportedFormatsTip = "Supported audio formats: wav, mp3, m4a, aac, ogg, wma, amr; supported video formats: avi, wmv, mov, mp4, m4v, mpeg, ogg, flv."
minutesUploadLimitsTip = "The original uploaded media must be no larger than 6GB and no longer than 6 hours."
)
// MinutesUpload uploads a media file token to generate a minute.
var MinutesUpload = common.Shortcut{
Service: "minutes",
Command: "+upload",
Description: "Upload a media file token to generate a minute",
Risk: "write",
Scopes: []string{"minutes:minutes.upload:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "file_token of a supported audio/video file already uploaded to Drive", Required: true},
},
Tips: []string{
"This shortcut only accepts --file-token. Upload the local media file to Drive first with `lark-cli drive +upload`.",
minutesUploadSupportedFormatsTip,
minutesUploadLimitsTip,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
if fileToken == "" {
return output.ErrValidation("--file-token is required")
}
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/minutes/v1/minutes/upload").
Body(map[string]interface{}{"file_token": runtime.Str("file-token")})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
body := map[string]interface{}{
"file_token": fileToken,
}
data, err := runtime.CallAPI("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
if err != nil {
return err
}
minuteURL := common.GetString(data, "minute_url")
outData := map[string]interface{}{
"minute_url": minuteURL,
}
runtime.OutFormat(outData, nil, nil)
return nil
},
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func TestMinutesUpload_Validate(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing file token",
args: []string{"+upload", "--as", "user"},
wantErr: "required flag(s) \"file-token\" not set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "minutes"}
MinutesUpload.Mount(parent, f)
parent.SetArgs(tt.args)
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
}
})
}
}
func TestMinutesUpload_HelpMetadata(t *testing.T) {
if len(MinutesUpload.Flags) == 0 {
t.Fatal("expected file-token flag metadata")
}
if got := MinutesUpload.Flags[0].Desc; !strings.Contains(got, "supported audio/video file") {
t.Fatalf("file-token description = %q, want supported media guidance", got)
}
joinedTips := strings.Join(MinutesUpload.Tips, "\n")
for _, want := range []string{
"drive +upload",
"wav, mp3, m4a, aac, ogg, wma, amr",
"avi, wmv, mov, mp4, m4v, mpeg, ogg, flv",
"6GB",
"6 hours",
} {
if !strings.Contains(joinedTips, want) {
t.Fatalf("tips should contain %q, got:\n%s", want, joinedTips)
}
}
}
func TestMinutesUpload_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "POST") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/upload") {
t.Errorf("expected POST /open-apis/minutes/v1/minutes/upload, got:\n%s", out)
}
if !strings.Contains(out, "boxcn123456") {
t.Errorf("expected file token in body, got:\n%s", out)
}
}
func TestMinutesUpload_Execute(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPost,
URL: "/open-apis/minutes/v1/minutes/upload",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"minute_url": "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c",
},
},
})
err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var res map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
dataMap, _ := res["data"].(map[string]interface{})
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
}
}

View File

@@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MinutesSearch,
MinutesDownload,
MinutesUpload,
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/shortcuts/event"
"github.com/larksuite/cli/shortcuts/im"
"github.com/larksuite/cli/shortcuts/mail"
"github.com/larksuite/cli/shortcuts/markdown"
"github.com/larksuite/cli/shortcuts/minutes"
"github.com/larksuite/cli/shortcuts/sheets"
"github.com/larksuite/cli/shortcuts/slides"
@@ -42,6 +43,7 @@ func init() {
allShortcuts = append(allShortcuts, base.Shortcuts()...)
allShortcuts = append(allShortcuts, event.Shortcuts()...)
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
@@ -90,6 +92,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
}
program.AddCommand(svc)
}
if service == "docs" {
doc.ConfigureServiceHelp(svc)
}
for _, shortcut := range shortcuts {
shortcut.MountWithContext(ctx, svc, f)

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package shortcuts
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, &cmdutil.Factory{})
for _, path := range [][]string{
{"markdown", "+create"},
{"markdown", "+fetch"},
{"markdown", "+overwrite"},
} {
cmd, _, err := program.Find(path)
if err != nil {
t.Fatalf("find markdown shortcut %v: %v", path, err)
}
if cmd == nil || cmd.Name() != path[1] {
t.Fatalf("markdown shortcut not mounted: %#v", cmd)
}
}
}

View File

@@ -4,16 +4,45 @@
package shortcuts
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func newRegisterTestFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{})
return f
}
func newRegisterTestProgramWithTipsHelp() *cobra.Command {
program := &cobra.Command{Use: "root"}
defaultHelp := program.HelpFunc()
program.SetHelpFunc(func(cmd *cobra.Command, args []string) {
defaultHelp(cmd, args)
tips := cmdutil.GetTips(cmd)
if len(tips) == 0 {
return
}
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range tips {
fmt.Fprintf(out, " • %s\n", tip)
}
})
return program
}
func TestAllShortcutsScopesNotNil(t *testing.T) {
for _, s := range allShortcuts {
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
@@ -48,7 +77,7 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, &cmdutil.Factory{})
RegisterShortcuts(program, newRegisterTestFactory(t))
baseCmd, _, err := program.Find([]string{"base"})
if err != nil {
@@ -69,7 +98,7 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, &cmdutil.Factory{})
RegisterShortcuts(program, newRegisterTestFactory(t))
previewCmd, _, err := program.Find([]string{"docs", "+media-preview"})
if err != nil {
@@ -80,12 +109,182 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
}
}
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
docsCmd, _, err := program.Find([]string{"docs"})
if err != nil {
t.Fatalf("find docs command: %v", err)
}
if docsCmd == nil || docsCmd.Name() != "docs" {
t.Fatalf("docs command not mounted: %#v", docsCmd)
}
if docsCmd.Flags().Lookup("api-version") == nil {
t.Fatal("docs command should expose --api-version for versioned help")
}
if !strings.Contains(docsCmd.Long, "Document and content operations.") {
t.Fatalf("docs long help missing default description:\n%s", docsCmd.Long)
}
var defaultHelp bytes.Buffer
docsCmd.SetOut(&defaultHelp)
if err := docsCmd.Help(); err != nil {
t.Fatalf("docs help failed: %v", err)
}
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",
} {
if !strings.Contains(defaultHelp.String(), want) {
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String())
}
}
}
func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
docsCmd, _, err := program.Find([]string{"docs"})
if err != nil {
t.Fatalf("find docs command: %v", err)
}
if err := docsCmd.Flags().Set("api-version", "v2"); err != nil {
t.Fatalf("set docs api-version: %v", err)
}
var out bytes.Buffer
docsCmd.SetOut(&out)
if err := docsCmd.Help(); err != nil {
t.Fatalf("docs v2 help failed: %v", err)
}
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",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs v2 help missing %q:\n%s", want, out.String())
}
}
}
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
tests := []struct {
name string
shortcut string
apiVersion string
shortcutHelp string
versionedFlag string
}{
{
name: "create v1",
shortcut: "+create",
apiVersion: "v1",
shortcutHelp: "Create a Lark document",
versionedFlag: "--markdown",
},
{
name: "create v2",
shortcut: "+create",
apiVersion: "v2",
shortcutHelp: "Create a Lark document",
versionedFlag: "--content",
},
{
name: "fetch v1",
shortcut: "+fetch",
apiVersion: "v1",
shortcutHelp: "Fetch Lark document content",
versionedFlag: "--offset",
},
{
name: "fetch v2",
shortcut: "+fetch",
apiVersion: "v2",
shortcutHelp: "Fetch Lark document content",
versionedFlag: "partial read scope",
},
{
name: "update v1",
shortcut: "+update",
apiVersion: "v1",
shortcutHelp: "Update a Lark document",
versionedFlag: "--mode",
},
{
name: "update v2",
shortcut: "+update",
apiVersion: "v2",
shortcutHelp: "Update a Lark document",
versionedFlag: "--command",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
program := newRegisterTestProgramWithTipsHelp()
RegisterShortcuts(program, newRegisterTestFactory(t))
cmd, _, err := program.Find([]string{"docs", tt.shortcut})
if err != nil {
t.Fatalf("find docs %s command: %v", tt.shortcut, err)
}
if cmd == nil || cmd.Name() != tt.shortcut {
t.Fatalf("docs %s shortcut not mounted: %#v", tt.shortcut, cmd)
}
if err := cmd.Flags().Set("api-version", tt.apiVersion); err != nil {
t.Fatalf("set docs %s api-version: %v", tt.shortcut, err)
}
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Help(); err != nil {
t.Fatalf("docs %s help failed: %v", tt.shortcut, err)
}
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",
} {
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())
}
}
})
}
}
func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
program := &cobra.Command{Use: "root"}
existingBase := &cobra.Command{Use: "base", Short: "existing base service"}
program.AddCommand(existingBase)
RegisterShortcuts(program, &cmdutil.Factory{})
RegisterShortcuts(program, newRegisterTestFactory(t))
baseCount := 0
for _, command := range program.Commands() {

View File

@@ -104,7 +104,7 @@ metadata:
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-search.md`](references/lark-base-record-search.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md)、[`lark-base-record-get.md`](references/lark-base-record-get.md) | 默认优先 `+record-list`仅当用户提供明确搜索关键词时使`+record-search`取数不用来做聚合分析;`--limit` 最大 `200`;仅在用户明确需要时继续翻页;`+record-list` 只能串行执行 |
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide已知 `record_id` `+record-get`明确关键词用 `+record-search`普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query` |
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token``+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403 |
@@ -119,7 +119,7 @@ metadata:
|------|------------------|----------------|----------|
| `+view-list / +view-get` | 列出视图,或获取视图详情 | [`lark-base-view-list.md`](references/lark-base-view-list.md)、[`lark-base-view-get.md`](references/lark-base-view-get.md) | `+view-list` 只能串行执行;`+view-get` 适合查看已有视图配置 |
| `+view-create / +view-delete / +view-rename` | 创建、删除或重命名视图 | [`lark-base-view-create.md`](references/lark-base-view-create.md)、[`lark-base-view-delete.md`](references/lark-base-view-delete.md)、[`lark-base-view-rename.md`](references/lark-base-view-rename.md) | 创建前先确认表和视图类型;删除前先确认目标;用户已明确新名字时可直接重命名 |
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
| `+view-get-sort / +view-set-sort` | 读取或配置排序 | [`lark-base-view-get-sort.md`](references/lark-base-view-get-sort.md)、[`lark-base-view-set-sort.md`](references/lark-base-view-set-sort.md) | 字段名必须来自真实结构 |
| `+view-get-group / +view-set-group` | 读取或配置分组 | [`lark-base-view-get-group.md`](references/lark-base-view-get-group.md)、[`lark-base-view-set-group.md`](references/lark-base-view-set-group.md) | 字段名必须来自真实结构 |
| `+view-get-visible-fields / +view-set-visible-fields` | 读取或配置视图可见字段 | [`lark-base-view-get-visible-fields.md`](references/lark-base-view-get-visible-fields.md)、[`lark-base-view-set-visible-fields.md`](references/lark-base-view-set-visible-fields.md) | 用于控制视图中的字段顺序与可见性;字段名必须来自真实结构 |
@@ -297,7 +297,7 @@ lark-cli auth login --domain base
7. 聚合分析与取数分流;统计走 `+data-query`,关键词检索走 `+record-search`,明细走 `+record-list / +record-get`
8. 筛选查询按视图能力执行;先用 `+view-set-filter` 配置筛选,再结合 `+record-list` 读取。
9. Base 场景不要改走裸 API不要切去 `lark-cli api /open-apis/bitable/v1/...`
10. 统一使用 `--base-token`,不使用旧 `--app-token`
10. 统一使用 `--base-token`
11. workflow 场景先读 schema不要凭自然语言猜 `type`
12. dashboard 场景先读 guide提到图表、看板、block 就先进入 dashboard 模块。
13. formula / lookup 场景先读 guide没读 guide 前不要直接创建或更新。

View File

@@ -1,6 +1,6 @@
# 飞书多维表格使用场景完整示例base
本文档提供基于 `lark-cli base ...` 的完整示例,覆盖 unified Shortcut 与当前 `base/v3` 原生 API 的常见组合方式
本文档提供基于 `lark-cli base +...` shortcut 的完整示例。
> **返回**: [SKILL.md](../SKILL.md) | **参考**: [shortcut 字段 JSON 规范](lark-base-shortcut-field-properties.md) · [CellValue 规范](lark-base-cell-value.md)
@@ -24,26 +24,28 @@ lark-cli base +table-create \
---
## 场景 2使用原生 API 创建数据表并查看字段
## 场景 2创建数据表并查看字段
适合需要精确观察 `base/v3` 请求参数和响应结构的场景。原生 API 直接使用 `base` service
适合需要先建表、再确认字段结构的场景
### 步骤 1在已有 Base 中创建数据表
```bash
lark-cli base tables create \
--params '{"base_token":"bascnXXXXXXXX"}' \
--data '{"name":"客户管理表"}'
lark-cli base +table-create \
--base-token bascnXXXXXXXX \
--name "客户管理表"
```
### 步骤 2列出字段
```bash
lark-cli base table.fields list \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","limit":100}'
lark-cli base +field-list \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--limit 100
```
> 提示:当前 `base/v3` 不再使用旧的 `app_token` / `app.table.*` 路径,统一改为 `base_token` + `tables` / `table.fields` / `table.records`
> 提示:Base token 统一通过 `--base-token` 传入;表 ID 统一通过 `--table-id` 传入
---
@@ -52,9 +54,10 @@ lark-cli base table.fields list \
### 新增记录
```bash
lark-cli base table.records create \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX"}' \
--data '{
lark-cli base +record-upsert \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--json '{
"客户名称":"字节跳动",
"负责人":[{"id":"ou_xxx"}],
"状态":"进行中"
@@ -64,16 +67,20 @@ lark-cli base table.records create \
### 列出记录
```bash
lark-cli base table.records list \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","limit":100}'
lark-cli base +record-list \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--limit 100
```
### 更新记录
```bash
lark-cli base table.records patch \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","record_id":"recXXXXXXXX"}' \
--data '{
lark-cli base +record-upsert \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--record-id recXXXXXXXX \
--json '{
"状态":"已完成"
}'
```
@@ -81,22 +88,27 @@ lark-cli base table.records patch \
### 删除记录
```bash
lark-cli base table.records delete \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","record_id":"recXXXXXXXX"}'
lark-cli base +record-delete \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--record-id recXXXXXXXX \
--yes
```
---
## 场景 4配置视图筛选后按视图读取记录
当前 `base/v3` 原生 spec 没有独立 `search` 方法。需要筛选查询时,推荐先写视图筛选,再通过 `view_id` 读取记录。
需要筛选查询时,推荐先写视图筛选,再通过 `view_id` 读取记录。
### 更新视图筛选条件
```bash
lark-cli base view.filter update \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","view_id":"vewXXXXXXXX"}' \
--data '{
lark-cli base +view-set-filter \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--view-id vewXXXXXXXX \
--json '{
"logic":"and",
"conditions":[
{
@@ -111,8 +123,11 @@ lark-cli base view.filter update \
### 按视图读取记录
```bash
lark-cli base table.records list \
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","view_id":"vewXXXXXXXX","limit":100}'
lark-cli base +record-list \
--base-token bascnXXXXXXXX \
--table-id tblXXXXXXXX \
--view-id vewXXXXXXXX \
--limit 100
```
---
@@ -123,8 +138,3 @@ lark-cli base table.records list \
- 需要按业务字段名做 upsert 时,优先 `lark-cli base +record-upsert`
- 需要配置筛选视图时,优先 `lark-cli base +view-set-filter`
- 需要记录历史时,优先 `lark-cli base +record-history-list`
原生 API 更适合两类场景:
- 需要逐步核对 `schema base.<resource>.<method>` 的请求参数
- 需要精确控制单次表 / 字段 / 记录 / 视图操作

View File

@@ -43,7 +43,7 @@ POST /open-apis/base/v3/bases/:base_token/copy
- CLI 会额外标记 `copied: true`
- 回复结果时,必须主动返回新 Base 的可访问链接:
- 优先使用返回结果中的 `base.url`
- 同时返回新 Base 的 token;字段名以实际返回为准,常见为 `base_token``app_token`
- 同时返回新 Base 的 token
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
> [!IMPORTANT]

View File

@@ -38,7 +38,7 @@ POST /open-apis/base/v3/bases
- CLI 会额外标记 `created: true`
- 回复结果时,必须主动返回新 Base 的可访问链接:
- 优先使用返回结果中的 `base.url`
- 同时返回新 Base 的 token;字段名以实际返回为准,常见为 `base_token``app_token`
- 同时返回新 Base 的 token
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
> [!IMPORTANT]

View File

@@ -57,7 +57,7 @@ lark-cli base +data-query \
| 参数 | 必填 | 说明 |
|------------------------|------|------|
| `--base-token <token>` | 是 | 多维表格 App Tokenbase_token |
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--dsl <json>` | 是 | LiteQuery Protocol JSON DSL 查询语句 |
## 如何从链接中提取参数
@@ -65,7 +65,7 @@ lark-cli base +data-query \
用户通常会提供如下 URL
```
https://example.feishu.cn/base/<app_token>?table=<table_id>
https://example.feishu.cn/base/<base_token>?table=<table_id>
```
- `--base-token`:取 `/base/` 后面的字符串
@@ -83,7 +83,7 @@ POST /open-apis/base/v3/bases/:base_token/data/query
| 参数 | 必填 | 说明 |
|------|------|------|
| `base_token` | 是 | 多维表格 App Token |
| `base_token` | 是 | Base Token |
**Request Body — DSL 结构:**
@@ -387,7 +387,7 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
## 工作流
1. 确认 base-token 和 table-id
2. **先查表结构**:执行 `lark-cli base app.table.fields list --params '{"app_token":"<token>","table_id":"<id>"}'`
2. **先查表结构**:执行 `lark-cli base +field-list --base-token <base_token> --table-id <table_id>`
3. 从返回的字段列表中获取 field_nameDSL 中使用的字段名称)
4. 根据字段信息构造 DSL JSON
5. 执行 +data-query
@@ -399,7 +399,7 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
## 坑点
- ⚠️ **必须先查表结构**DSL 的 `field_name` 必须与表中字段名称精确匹配(区分大小写),不能凭猜测构造。先用 `base app.table.fields list` 获取真实字段名
- ⚠️ **必须先查表结构**DSL 的 `field_name` 必须与表中字段名称精确匹配(区分大小写),不能凭猜测构造。先用 `lark-cli base +field-list --base-token <base_token> --table-id <table_id>` 获取真实字段名
- ⚠️ **权限要求按文档类型分流**:普通多维表格只需文档**阅读权限**;高级权限多维表格必须是文档管理员(**FA / Full Access**),否则返回权限错误
- ⚠️ **alias 不支持中文**dimensions 和 measures 的 alias 必须使用英文(如 `dim_city``total_amount`),中文 alias 会导致错误
- ⚠️ **API 路径是 `base/v3`**:本接口路径为 `/open-apis/base/v3/bases/:base_token/data/query`,不是 `bitable/v1`。两者完全不同,用错版本号会返回 `[2200] Internal Error`

View File

@@ -44,7 +44,7 @@ lark-cli base +form-create \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | 多维表格 App tokenbase_token |
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--table-id <id>` | 是 | 数据表 ID |
| `--name <name>` | 是 | 表单名称 |
| `--description <string>` | 否 | 表单描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)` |
@@ -76,7 +76,7 @@ lark-cli base +form-create \
> [!CAUTION]
> 这是**写入操作** — 执行前必须向用户确认。
1. 确认目标 `app_token``table_id`
1. 确认目标 `base_token``table_id`
2. 确认表单名称和描述
3. 执行命令
4. 报告返回的 `form_id`,后续可用于添加问题(`+form-questions-create`

View File

@@ -25,7 +25,7 @@ lark-cli base +form-delete \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | 多维表格 App tokenbase_token |
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--table-id <id>` | 是 | 数据表 ID |
| `--form-id <id>` | 是 | 要删除的表单 ID |
| `--as` | 否 | 身份user默认\| bot |

View File

@@ -32,7 +32,7 @@ lark-cli base +form-get \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | 多维表格 App tokenbase_token |
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--table-id <id>` | 是 | 数据表 ID |
| `--form-id <id>` | 是 | 表单 ID |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |

View File

@@ -29,7 +29,7 @@ lark-cli base +form-list \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | 多维表格 App tokenbase_token |
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--table-id <id>` | 是 | 数据表 ID |
| `--page-size <n>` | 否 | 每次请求的分页大小,默认 100最大 100 |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |
@@ -64,7 +64,7 @@ JSON 输出示例(`--format json`,默认):
## 提示
- `base_token` 在多维表格 URL 中可找到(形如 `bascnXXXX`
- `table_id` 可通过 `lark-cli base app.tables list --app-token <token> --params '{"app_token":"<token>"}'` 获取
- `table_id` 可通过 `lark-cli base +table-list --base-token <base_token>` 获取
- 如无表单,输出 `forms: []`
## 参考

View File

@@ -56,7 +56,7 @@ lark-cli base +form-questions-create \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | 多维表格 App tokenbase_token |
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--table-id <id>` | 是 | 数据表 ID |
| `--form-id <id>` | 是 | 表单 ID |
| `--questions <json>` | 是 | 问题 JSON 数组,最多 10 个(见下方格式) |

View File

@@ -34,7 +34,7 @@ lark-cli base +form-questions-delete \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | 多维表格 App tokenbase_token |
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--table-id <id>` | 是 | 数据表 ID |
| `--form-id <id>` | 是 | 表单 ID |
| `--question-ids <json>` | 是 | 要删除的问题 ID JSON 数组,最多 10 个,如 `'["q_001","q_002"]'` |

View File

@@ -32,7 +32,7 @@ lark-cli base +form-questions-list \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | 多维表格 App tokenbase_token |
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--table-id <id>` | 是 | 数据表 ID |
| `--form-id <id>` | 是 | 表单 ID |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |

View File

@@ -42,7 +42,7 @@ lark-cli base +form-questions-update \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | 多维表格 App tokenbase_token |
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--table-id <id>` | 是 | 数据表 ID |
| `--form-id <id>` | 是 | 表单 ID |
| `--questions <json>` | 是 | 问题更新 JSON 数组,最多 10 个(见下方格式) |

View File

@@ -41,7 +41,7 @@ lark-cli base +form-update \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | 多维表格 App tokenbase_token |
| `--base-token <token>` | 是 | Base Tokenbase_token |
| `--table-id <id>` | 是 | 数据表 ID |
| `--form-id <id>` | 是 | 表单 ID |
| `--name <name>` | 否 | 新的表单名称 |

View File

@@ -1,38 +0,0 @@
# base +record-get
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
获取单条记录,可选裁剪输出字段。
## 推荐命令
```bash
lark-cli base +record-get \
--base-token <base_token> \
--table-id <table_id> \
--record-id <record_id>
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--record-id <id>` | 是 | 记录 ID |
## API 入参详情
**HTTP 方法和路径:**
```
GET /open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id
```
## 返回重点
- 成功时直接返回接口 `data` 字段内容。
## 参考
- [lark-base-record.md](lark-base-record.md) — record 索引页

View File

@@ -1,83 +0,0 @@
# base +record-list
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
分页列出一张表里的记录;可按视图过滤,也可按字段裁剪返回列。
> 默认优先使用 `+record-list`;仅当用户提供明确搜索关键词时,才使用 [lark-base-record-search.md](lark-base-record-search.md)。
## 返回关键字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `has_more` | boolean | 是否还有下一页数据;`true` 表示可继续翻页,`false` 表示已到末页 |
| `query_context.record_scope` | string | 记录范围:`all_records`(全表)或 `view_filtered_records`(按视图过滤) |
| `query_context.field_scope` | string | 字段范围:`selected_fields`(显式传 `--field-id`/ `view_visible_fields`(未传 `--field-id` 且按视图可见字段)/ `all_fields`(未传 `--field-id` 且无视图限制) |
## 字段返回优先级
- `query_context.field_scope` 的优先级为:`selected_fields`explicit `--field-id` > `view_visible_fields`view visible fields > `all_fields`table all fields
## 按需翻页规则
1. 先执行一次 `+record-list` 获取首批结果。
2. 检查返回的 `has_more`
3. 仅当同时满足以下条件时才继续翻页:
- `has_more = true`
- 用户问题需要更多数据(例如“全部导出”“统计全量明细”“继续加载下一页”)
4. 若用户只要部分结果(例如“先看前 20 条”“先给示例数据”),即使 `has_more = true` 也不继续翻页。
5. 继续翻页时,`offset` 按已读取数量递增,直到满足用户需求或 `has_more = false`
## 推荐命令
```bash
lark-cli base +record-list \
--base-token <base_token> \
--table-id <table_id> \
--offset 0 \
--limit 100
lark-cli base +record-list \
--base-token <base_token> \
--table-id <table_id> \
--view-id <view_id> \
--field-id fldStatus \
--field-id 项目名称 \
--offset 0 \
--limit 50
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--view-id <id>` | 否 | 视图 ID传入后只读该视图结果 |
| `--field-id <id_or_name>` | 否 | 字段 ID 或字段名;可重复传入多个 `--field-id` 裁剪返回列 |
| `--offset <n>` | 否 | 分页偏移,默认 `0` |
| `--limit <n>` | 否 | 分页大小,默认 `100`,范围 `1-200`(最大 `200`,超过会报错) |
## API 入参详情
**HTTP 方法和路径:**
```
GET /open-apis/base/v3/bases/:base_token/tables/:table_id/records
```
- 查询参数会附带 `view_id / field_id(repeatable) / offset / limit`
## 坑点
- ⚠️ `+record-list` 禁止并发调用;批量拉多个视图或多张表时必须串行。
- ⚠️ `--limit` 最大 `200`,不要传超过 `200` 的值。
- ⚠️ 分页时优先根据返回的 `has_more` 判断是否继续请求,不要盲目预拉全量数据。
- ⚠️ `--field-id` 接受字段 ID 或字段名。
- ⚠️ 复杂筛选优先落到视图里,再用 `--view-id` 读取。
## 参考
- [lark-base-record.md](lark-base-record.md) — record 索引页
- [lark-base-view-set-filter.md](lark-base-view-set-filter.md) — 配筛选

View File

@@ -0,0 +1,86 @@
# base record read SOP
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和全局参数。
记录读取由 6 个功能组合完成选路、字段投影、视图预处理、分页与范围、返回结构解释、link 关联读取。
## 1. 读取选路
| 场景 | 使用方式 | 规则 |
|------|------|------|
| 已知 `record_id` | `+record-get` | 只读单条记录,不要用 search/list 反查。 |
| 明确关键词检索 | `+record-search` | 只用于文本关键词检索;金额、状态、日期等结构化条件不要用 search。 |
| 普通明细读取 / 导出 / 查看前 N 条 | `+record-list` | 优先加 `--view-id` 时只读该视图可见记录与可见字段;或者加 `--field-id` 手动裁剪字段;不传 `--view-id` 时会读取全表。 |
| 明确筛选 / 排序 / Top N / Bottom N 且需要原始记录或 `record_id` | 创建带 filter + sort 的临时视图 + `+record-list --view-id` | 让视图完成 filter/sort projectionLLM 不擅长手工筛选排序,建议用视图完成。 |
| 统计 / 聚合结果且不需要 `record_id` | 转到 [`lark-base-data-query.md`](lark-base-data-query.md) | `data-query` 是特殊分析 DSL不是记录读取工具。 |
## 2. 字段投影
- `FieldListFirst`: 不清楚字段结构时先 `+field-list`,确认筛选字段、排序字段、展示字段、关联字段、业务唯一键字段。
- `UseRealField`: 字段名和字段 ID 必须来自 `+field-list` 返回,不要凭自然语言猜字段名。
- `MinimalProjection`: 每次读取只返回本次任务需要的字段;`+record-list` 用重复 `--field-id`,视图读取用 `+view-set-visible-fields`
- `FieldScopePriority`: 返回字段优先级为显式投影字段(`+record-list --field-id` / `record-search select_fields` > 视图可见字段 > 全表字段;需要稳定列范围时必须显式投影。
- `LongFieldAvoidance`: 默认不要读取 `trace``raw`、长文本、附件等高噪声字段,除非任务明确需要。
- `BusinessKey`: 后续要定位、更新或解释记录时投影中必须包含可识别业务字段例如订单号、日报ID、姓名、编号。
## 3. 视图预处理
适用于结构化筛选、排序、最高/最低、倒数、Top/Bottom N、按条件找记录等场景。
1. `+field-list` 获取字段 ID、字段名和字段类型。
2. `+view-create` 创建临时 `grid` 视图,名称带任务语义,例如 `tmp_query_销售额升序`
3. `+view-set-filter` 设置筛选条件;空值是否参与必须按用户语义判断。
4. `+view-set-sort` 设置排序条件;最高/最新用降序,最低/最早/倒数用升序。
5. `+view-set-visible-fields` 设置投影字段,只保留业务键、排序字段、筛选解释字段、需要展示或二跳的字段。
6. `+record-list --view-id <view_id> --limit <N>` 读取结果;不要再从未排序全表输出中手动挑选。
## 4. 分页与范围
- `ViewScope`: URL 带 `view_id` 时先判断用户是否要求“该视图下”;全表问题不要误用 URL 视图范围,应该根据需求创建合适的临时视图完成查询任务。
- `ViewIdScope`: `+record-list --view-id` 是作用域参数;仅用于用户指定的视图,或本次任务主动创建的临时筛选 / 排序 / 投影视图。
- `NeedAllPages`: 用户要求全部、导出、统计、最高/最低且未用视图/limit 限定时,必须检查 `has_more` 并串行翻页。
- `LimitWhenScoped`: 用户只要示例、前 N 条、Top/Bottom N使用 `--limit` 控制结果规模。
- `NoConcurrentList`: `+record-list` 禁止并发调用;分页和多表读取必须串行。
- `DataQueryScope`: `data-query` 的筛选 DSL 与视图筛选不是同一套语法;不要混用。
## 5. 返回结构解释
- `ColumnMapping`: `fields` / `field_id_list` 定义 `data` 每列含义;解释记录前先建立列到字段名的映射。
- `RowMapping`: `record_id_list[i]``data[i]` 是同一行;需要后续定位、更新或关联时,按下标整理成 `record_id + 字段名:值` 的小表。
- `BusinessMatch`: 后续引用目标记录时按业务字段匹配,不靠肉眼数行号。
- `FieldType`: 按字段类型解释值数字、货币、日期、人员、formula、lookup、attachment、link 不要当普通文本处理。
- `EmptyValue`: 空值参与筛选或排序前必须明确语义;不要默认把空值当 `0`、空字符串或有效状态。
- `AnswerCheck`: 最终回答前复核答案记录来自读取结果、筛选排序已应用、字段含义和 record_id 映射无误。
## 6. link 关联字段读取
link 字段是关联单元格;读取结果通常是关联表的 `record_id` 数组,不是用户可读名称。
| 步骤 | 做法 |
|------|------|
| 识别 link 字段 | 用 `+field-list` 查看字段类型为 `link`,并读取 `link_table` 确认关联目标表。 |
| 读取当前表 | 在当前表 `+record-list` / `+record-get` 中保留 link 字段和业务键字段。 |
| 解析单元格值 | link 单元格通常形如 `[{"id":"rec..."}]`;提取其中每个 `id` 作为关联表 `record_id`。 |
| 读取关联表 | 到 `link_table` 使用 `+record-get --record-id <rec...>` 或裁剪后的 `+record-list` 读取显示字段。 |
| 建立映射 | 形成 `关联record_id -> 显示字段值` 映射,再回填当前表结果。 |
| 多值处理 | 多个关联值保持原顺序;可去重批量读取,但回答时按原单元格顺序输出。 |
禁止事项:
- 不要把 link 单元格里的 `record_id` 当作最终答案。
- 不要用 `+record-search` 搜索 link `record_id` 来查关联记录。
- 不要凭关联 `record_id` 猜名称、负责人、门店等显示值。
- 不要只看当前表字段名推断关联表结构;跨表读取前必须拿关联表字段结构。
## 7. 命令 help
- `HelpFirst`: 参数、示例、JSON shape 和取值约束以 `lark-cli base +record-get --help``+record-search --help``+record-list --help` 为准。
- `RecordSearchJson`: 构造 `+record-search --json` 前先看 `+record-search --help`,确认 `keyword/search_fields/select_fields/view_id/offset/limit` 的结构和约束。
- `RecordListProjection`: 构造 `+record-list` 前先看 `+record-list --help`,确认 `--field-id``--view-id``--offset``--limit` 的语义。
## 参考
- [lark-base-view-set-filter.md](lark-base-view-set-filter.md)
- [lark-base-view-set-sort.md](lark-base-view-set-sort.md)
- [lark-base-view-set-visible-fields.md](lark-base-view-set-visible-fields.md)
- [lark-base-data-query.md](lark-base-data-query.md)

View File

@@ -1,72 +0,0 @@
# base +record-search
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
按关键词检索记录CLI 侧通过 `--json` 透传请求体。
## 适用场景
- 需要关键词检索记录。
- 用户已提供明确搜索关键词(`keyword`)。
- 需要附带 `view_id / select_fields` 控制检索范围与返回字段。
- 不用于聚合统计。涉及 SUM/AVG/COUNT/MAX/MIN 时改用 `+data-query`
## 推荐命令
```bash
lark-cli base +record-search \
--base-token <base_token> \
--table-id <table_id> \
--json '{"keyword":"Created","search_fields":["Title","<field_id>"],"offset":0,"limit":100}'
lark-cli base +record-search \
--base-token <base_token> \
--table-id <table_id> \
--json '{"view_id":"<view_id>","keyword":"Alice","search_fields":["Title","Owner"],"select_fields":["Title","Owner","Status"],"offset":0,"limit":50}'
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--json <object>` | 是 | 搜索请求体 JSON结构要求见下方“JSON 要求”) |
## 返回关键字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `query_context.record_scope` | string | 记录范围:`all_records`(全表)/ `view_filtered_records`(按 `view_id` 限定到视图记录) |
| `query_context.field_scope` | string | 字段范围:`selected_fields`(显式传 `select_fields`/ `view_visible_fields`(未传 `select_fields` 且按视图可见字段)/ `all_fields`(未传 `select_fields` 且无视图限制) |
| `query_context.search_scope` | string | 实际参与搜索的字段集合,格式类似 `fieldTitle(Title), fieldOwner(Owner)` |
## API 入参详情
**HTTP 方法和路径:**
```http
POST /open-apis/base/v3/bases/:base_token/tables/:table_id/records/search
```
### JSON 格式要求
| 字段 | 必填 | 类型 | 约束 |
|------|------|------|------|
| `view_id` | 否 | string | 传入后仅在该视图范围内搜索,并默认按该视图可见字段返回结果 |
| `keyword` | 是 | string | 非空,最小长度 `1` |
| `search_fields` | 是 | string[] | 数组长度 `1-20`;每项是字段 `field_id` 或字段名,代表在这些字段中做关键词搜索 |
| `select_fields` | 否 | string[] | 数组长度 `<=50`;每项是字段 `field_id` 或字段名 |
| `offset` | 否 | int | `>=0`,默认 `0` |
| `limit` | 否 | int | `1-200`,默认 `10` |
## 坑点
- ⚠️ `+record-search` 用于检索,不用于聚合分析;聚合场景使用 `+data-query`
- ⚠️ 部分字段不支持搜索(例如 `attachment``link`);传入后通常不会报错,但可能导致无法命中对应记录。
## 参考
- [lark-base-record.md](lark-base-record.md) — record 索引页
- [lark-base-record-list.md](lark-base-record-list.md) — 分页列表读取
- [lark-base-data-query.md](lark-base-data-query.md) — 聚合分析

View File

@@ -8,9 +8,7 @@ record 相关命令索引。
| 文档 | 命令 | 说明 |
|------|------|------|
| [lark-base-record-search.md](lark-base-record-search.md) | `+record-search` | 按关键词和字段范围检索记录 |
| [lark-base-record-list.md](lark-base-record-list.md) | `+record-list` | 分页列记录 |
| [lark-base-record-get.md](lark-base-record-get.md) | `+record-get` | 获取单条记录 |
| [lark-base-record-read-sop.md](lark-base-record-read-sop.md) | `+record-get` / `+record-search` / `+record-list` | 记录读取统一选路、筛选排序投影 SOP |
| [lark-base-record-upsert.md](lark-base-record-upsert.md) | `+record-upsert` | 创建或更新记录 |
| [lark-base-record-batch-create.md](lark-base-record-batch-create.md) | `+record-batch-create` | 按 `fields/rows` 批量创建记录 |
| [lark-base-record-batch-update.md](lark-base-record-batch-update.md) | `+record-batch-update` | 批量更新记录 |
@@ -21,7 +19,8 @@ record 相关命令索引。
## 说明
- 聚合页只保留目录职责;每个命令的详细说明请进入对应单命令文档
- 读取记录前优先阅读 [lark-base-record-read-sop.md](lark-base-record-read-sop.md),它合并了 `+record-get` / `+record-search` / `+record-list` 的选路和 SOP
- 聚合页只保留目录职责;写入、删除、历史等命令的详细说明请进入对应单命令文档。
- 所有 `+xxx-list` 调用都必须串行执行;若要批量跑多个 list 请求,只能串行执行。
- `+record-list` 支持重复传参 `--field-id` 做字段筛选。
- 写记录 JSON 前优先阅读 [lark-base-cell-value.md](lark-base-cell-value.md)。

View File

@@ -1,11 +1,11 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档:创建和编辑飞书文档。默认使用 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: "飞书云文档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 做资源发现。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --help"
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help"
---
# docs (v2)

View File

@@ -74,7 +74,7 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
## 最佳实践
- 文档标题从内容中自动提取XML `<title>` 或 Markdown `#`),不要在内容开头重复写标题
- 创建较长的文档时,先创建基础内容,再用 `docs +update --command block_insert_after` 分段追加
- **创建较长的文档时只建骨架**`--content` 仅传标题 + 各级 heading + 简短占位摘要;正文留给后续 `docs +update --command append``block_insert_after` 分段追加。一次性塞超长 `--content` 既容易触发参数限制,调试也更难。
- **视觉丰富度**:必须遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block 丰富文档
## 参考

View File

@@ -49,21 +49,21 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
| `outline` | 不知道结构,先看目录 | `--max-depth`(标题层级上限) | 扁平列出所有标题,**包括嵌在容器里的内嵌标题**(如 callout 里的 h3这些 id 可直接作后续 `section` / `range` 端点 |
| `section` | 读某个标题对应的整节 | `--start-block-id`(必填) | 顶层标题 → 展开到下一同级/更高级标题前;容器内节点(含内嵌标题) → 按"最小包容单元"返回容器/表格切片,不做 heading 扩展;顶层非标题块 → 仅该块 |
| `range` | 已知精确起止 | `--start-block-id` / `--end-block-id` 至少一个;`-1` = 读到末尾 | 两端同顶层 → 顶层序列切片;两端同一容器 → 容器整体;两端同一表格 → 瘦身切片;**跨顶层 → 端点所在顶层块整块输出,不做瘦身** |
| `keyword` | 只有模糊关键词 | `--keyword`不区分大小写、子串,`\|` 分隔多 OR | 每处命中按"最小包容单元"输出;**自动去重**(同容器多命中 → 单个容器,同表格多行命中 → 合并切片) |
| `keyword` | 只有模糊关键词 | `--keyword`**多级自动 fallback**:子串 → 归一化 → 分词形变 → RE2 正则;`\|` 分隔多分支 OR | 每处命中按"最小包容单元"输出;**自动去重**(同容器多命中 → 单个容器,同表格多行命中 → 合并切片) |
> 💡 **多关键词用 `\|` 拼接OR 语义,任一命中即返回)**:例 `"部署\|发布\|上线"`,三词任一命中都进结果,适合**同义词/别名/多业务术语**一次召回(如 `bug\|缺陷\|故障`)。
**设置 `--scope` 时共用** `--context-before` / `--context-after` / `--max-depth`
- `--max-depth``outline` = 标题层级上限3 = h1~h3其它模式 = 被选块的子树遍历深度(`-1` 不限,`0` 仅块自身)。
- `--context-before/--context-after`**只对整块顶层单元生效**;命中落在容器/表格内(返回容器或切片)时 before/after 被忽略,需要更大范围改用 `section` / `range` 显式指定。
**决策顺序**(核心原则:**局部获取优于全量获取**能精确到节/区间就绝不全量拉取;**任何文档的第一次读取都应从 `outline` 开始**
1. **第一次接触文档 / 不知道结构**`outline` 探测目录(**强制首步,无论文档是"目标"还是"引用源"**),再回到 2/3 精读
2. 改/读某个**标题对应的整节** → `section`(最省心,**首选精读方式**
3. 精确自定义起止 / 跨节连续区间 → `range`
4. 只有模糊关键词 → `keyword`
5. **兜底**确实需要整篇文档时才不传 `--scope`(默认整篇);**不要为省事读整篇**,局部模式上下文更省、响应更快
**推荐双步流程**`outline --max-depth 3` 拿目录 → `section --start-block-id <标题id> --detail with-ids` 精读该节。
**决策顺序**(核心原则:**局部获取优于全量获取**根据需求形态选起点,必要时多步组合收敛范围
1. 需求**直接给出待查的具体术语/错误码/标识** → 直接走 `keyword` 粗匹配(多级 fallback 自动覆盖形变),需要更大上下文时用返回的 `top-block-id``section` / `range`
2. 需求**指向某个章节/标题**"修改 XX 章"、"总结第 3 节"、"关于 xx 的内容")→ 先 `outline --max-depth 3` 拿目录 → `section --start-block-id <标题id>` 精读
3. 已知**精确起止 / 跨节连续区间**`range`
4. **结构未知且无明确关键词/章节线索**`outline` 探测,再回到 2/3
5. **兜底**仅在确需整篇时才省略 `--scope`不要为省事直接读整篇
## 局部读取的输出结构:`<fragment>` 与 `<excerpt>`
@@ -108,7 +108,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
| `--start-block-id` | 否 | `range`/`section` 起始/锚点 id`section` 必填) |
| `--end-block-id` | 否 | `range` 结束 id`-1` 表示读到末尾 |
| `--keyword` | 否 | `keyword` 模式关键词;`\|` 分隔多 OR |
| `--keyword` | 否 | `keyword` 模式关键词**4 层自动 fallback**(子串 → 归一化 → 分词形变 → RE2 正则)`\|` 分隔多分支 OR |
| `--context-before` | 否 | 命中前拉几个兄弟块(仅对顶层单元生效,默认 `0` |
| `--context-after` | 否 | 命中后拉几个兄弟块(仅对顶层单元生效,默认 `0` |
| `--max-depth` | 否 | `outline` = 标题层级上限;其它 = 子树深度(`-1` 不限,默认) |

View File

@@ -85,7 +85,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
- 合并单元格仅起始格输出 `colspan` / `rowspan`,被合并的格不出现
# 六、美化系统
- 颜色优先使用命名色,也可写 `rgb(r,g,b)` / `rgba(r,g,b,a)`。**基础色(6 色)**gray, red, orange, yellow, green, blue
- 颜色优先使用命名色,也可写 `rgb(r,g,b)` / `rgba(r,g,b,a)`。**基础色(7 色)**red, orange, yellow, green, blue, purple, gray
| 属性 | 支持的命名色 |
|-|-|
| 文字颜色 `<span text-color>` | 基础色 |

View File

@@ -20,7 +20,9 @@
1. 分析用户需求:受众、目的、范围
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block
3. `docs +create --api-version v2` 创建文档:标题 + 开头 `<callout>` + 骨架(各级标题 + 简短占位摘要
3. `docs +create --api-version v2` **只建骨架**:标题 + 开头 `<callout>` + 各级标题 + 每节一句占位摘要
- ⚠️ **不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
- 完整内容留到第二波,由各 Agent 用 `docs +update --command append``block_insert_after` 分段写入。
### 第二波 — 内容撰写(并行 Agent
@@ -46,5 +48,3 @@
## Agent 子任务要求
Spawn Agent 时必须提供:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
章节较多时,先 `docs +create` 建骨架,再分段 `append` 追加,比一次性超长 `--content` 更可靠。

View File

@@ -19,6 +19,7 @@ metadata:
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"、"最近一周我打开过的 xxx"、"某人创建的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。老的 `docs +search` 进入维护期、后续会下线,不要新增对它的依赖。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
@@ -228,13 +229,16 @@ 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). `--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). 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. |
| [`+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 |
| [`+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 |
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
| [`+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). 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. |
| [`+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) |

View File

@@ -39,6 +39,14 @@ lark-cli drive +export \
--file-extension xlsx \
--output-dir ./exports
# 指定本地文件名(会按导出格式自动补扩展名)
lark-cli drive +export \
--token "<DOCX_TOKEN>" \
--doc-type docx \
--file-extension pdf \
--file-name "weekly-report.pdf" \
--output-dir ./exports
# 导出电子表格或多维表格为 csv 时,必须传 sub_id
lark-cli drive +export \
--token "<SHEET_OR_BITABLE_TOKEN>" \
@@ -70,6 +78,7 @@ lark-cli drive +export \
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` |
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` |
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
| `--file-name` | 否 | 覆盖默认本地文件名;如未带扩展名,会按 `--file-extension` 自动补齐 |
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
| `--overwrite` | 否 | 覆盖已存在文件 |
@@ -88,7 +97,8 @@ lark-cli drive +export \
lark-cli drive +export \
--token "<DOCX_TOKEN>" \
--doc-type docx \
--file-extension pdf
--file-extension pdf \
--file-name "weekly-report.pdf"
# 如果返回 ready=false / timed_out=true再继续查
lark-cli drive +task_result \
@@ -99,6 +109,7 @@ lark-cli drive +task_result \
# 查到 file_token 后下载
lark-cli drive +export-download \
--file-token "<EXPORTED_FILE_TOKEN>" \
--file-name "weekly-report.pdf" \
--output-dir ./exports
```

View File

@@ -0,0 +1,113 @@
# drive +pull
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
把飞书云空间的某个文件夹**单向、文件级**镜像到本地目录Drive → 本地)。命令递归列出 `--folder-token` 下所有 `type=file` 的文件,逐一下载到 `--local-dir` 对应的相对路径,子文件夹自动复刻为本地目录。
> ⚠️ **不是 directory-level mirror**`--delete-local` 只删除本地"多余"的常规文件,不删除空目录。如果云端把整个子文件夹删了,对应的本地子目录会留空(里面的文件被清掉,目录本身保留);想精确同步目录结构请自己 `rmdir` 处理空壳。
输出按"动作"分类:
| 字段 | 含义 |
|------|------|
| `summary.downloaded` | 成功下载的文件数 |
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
| `summary.failed` | 下载或写盘失败的文件数 |
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `action` / 失败时的 `error` |
`summary.failed > 0` 时命令以 **非零状态码**`exit=1``error.type=partial_failure`)退出,且同一份 `summary + items` 会在 `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`
## 命令
```bash
# 基础用法 —— 把云端 fldcXXX 镜像到 ./repo
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx
# 已存在的本地文件保持不动
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--if-exists skip
# 文件级镜像:下载新文件 + 删除云端没有的本地文件(不删空目录)
# --delete-local 必须搭配 --yes否则会被 Validate 直接拒绝)
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--delete-local --yes
```
## 参数
| 标志 | 必填 | 类型 | 说明 |
|------|------|------|------|
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
| `--folder-token` | 是 | string | 源 Drive 文件夹 token |
| `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`(默认)/ `skip` |
| `--delete-local` | 否 | bool | 删除本地"云端没有的常规文件"**不删空目录**,因此是 file-level mirror**必须配合 `--yes`** |
| `--yes` | 否 | bool | 确认 `--delete-local`;不传时该破坏性操作在 Validate 阶段被拒绝 |
## 比较与下载范围
- **只下载 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)会被跳过 —— 它们没有等价的本地二进制可写盘,否则会变成产生噪声的"假"下载。
- 子文件夹会递归遍历rel_path 形如 `sub1/sub2/file.txt`,本地缺失的父目录会被自动创建。
- 已存在的本地文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自己改名再 pull。
## --delete-local 的安全行为
`--delete-local` 是命令里**唯一的破坏性 flag**,会按"本地有但云端没有"清理本地常规文件。设计上把它跟 `--yes` 强绑定,且与下载阶段的失败联动:
- `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。
- `--delete-local --yes`**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。
- `--delete-local --yes`**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
- 不传 `--delete-local``summary.deleted_local` 永远是 0命令对本地"多余"文件视而不见。
第 6 章里把 `+pull --delete-local` 标了 `high-risk-write`CLI 这边的实现等价于"未传 `--yes` 时拒绝执行",符合该约束的精神。
## 输出 schema
```json
{
"summary": {
"downloaded": 0,
"skipped": 0,
"failed": 0,
"deleted_local": 0
},
"items": [
{"rel_path": "...", "file_token": "...", "action": "downloaded"},
{"rel_path": "...", "file_token": "...", "action": "skipped"},
{"rel_path": "...", "file_token": "...", "action": "failed", "error": "..."},
{"rel_path": "...", "action": "deleted_local"},
{"rel_path": "...", "action": "delete_failed", "error": "..."}
]
}
```
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。删除条目(`deleted_local` / `delete_failed`)没有 `file_token`,因为该文件本来就只在本地。
## 性能注意
- 下载流量 ≈ 云端待下载文件的总字节数。pull 是**全量**写盘 —— 跟 `+status` 不一样,不会跳过"内容相同"的文件status 是按 hash 比较pull 是按 `--if-exists`),所以一次跑可能很重。
- 想避免重跑全量,可以先 `+status` 找出 `new_remote``modified`,再只对这些文件单独 `+download`
- 大文件会用 SDK 的流式下载(不会把整个 body 读进内存),但本地磁盘空间需要够。
## 所需 scope
| 操作 | scope |
|------|-------|
| 列出文件夹 / 子目录 | `drive:drive.metadata:readonly` |
| 下载文件 | `drive:file:download` |
如果当前 token 缺这些 scope命令会直接报 `missing_scope` 并提示重新登录。`drive:drive` 在部分企业被策略禁用,所以 +pull 故意只声明上面这两个细粒度 scope。
## 范围限制
`--local-dir` 只接受 cwd 内的相对路径。CLI 会先 `EvalSymlinks` 整条路径,再判断它是否仍落在 cwd 内 —— **指向 cwd 外的符号链接也会被拒**"在 cwd 内放一条软链指向外面" 这条捷径走不通,会直接撞上 `unsafe file path`
如果用户想 pull 到 cwd 之外的目录,**不要 agent 自己 `cd` 绕过**。可以选:让用户在外部把 agent 工作目录切换到目标的祖先后重启会话;或者把目标整体物理移动 / 拷贝到 cwd 内(不是软链);或者直接放弃这次同步,改用别的方式。
## 参考
- [lark-drive](../SKILL.md) —— 云空间全部命令
- [lark-shared](../../lark-shared/SKILL.md) —— 认证和全局参数
- [lark-drive-status](lark-drive-status.md) —— 下载前先看差异
- [lark-drive-download](lark-drive-download.md) —— 单文件按需拉取

View File

@@ -0,0 +1,137 @@
# drive +push
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
把本地目录**单向、文件级**镜像到飞书云空间的某个文件夹(本地 → Drive。命令递归列出 `--folder-token` 下的远端清单,遍历 `--local-dir` 的所有常规文件,按相对路径在 Drive 上新建、覆盖或跳过;可选地(`--delete-remote --yes`)删除云端"本地没有"的 `type=file`
> **"文件级镜像"≠"目录镜像"。** 命令只在文件维度收敛差异:本地多了文件就上传,本地少了文件且开了 `--delete-remote --yes` 就删远端文件。**远端只有的空目录、本地已删除的目录**都不会被收敛,云端目录树的多余结构不会被清理。如果需要"目录也要保持完全一致",得自行先 `+status` 找差异、再手动处理多余目录。
输出按"动作"分类:
| 字段 | 含义 |
|------|------|
| `summary.uploaded` | 成功新建或覆盖的文件数 |
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error` |
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `failed` / `delete_failed`
> 本地目录(包括空目录)会被镜像到 Drive新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token不会重复 `create_folder`,也不会出现在 `items[]` 里。
## 命令
```bash
# 基础用法 —— 把本地 ./repo 增量推送到云端 fldcXXX
# 默认 --if-exists=skip已经存在的远端文件保持不动只新增、不覆盖。
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx
# 显式覆盖远端同名文件(依赖 upload_all 的灰度协议字段,详见下文"覆盖语义"
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--if-exists overwrite
# 文件级镜像同步:上传 / 覆盖 + 删除本地不存在的远端文件
# --delete-remote 必须搭配 --yes否则会被 Validate 直接拒绝;
# 且 Validate 阶段会动态检查 space:document:delete scope缺权限会立刻失败
# 不会出现"上传成功了但是后面删除阶段挂了"的半同步状态)
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--if-exists overwrite --delete-remote --yes
```
## 参数
| 标志 | 必填 | 类型 | 说明 |
|------|------|------|------|
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
| `--folder-token` | 是 | string | 目标 Drive 文件夹 token |
| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`**默认**,安全)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义" |
| `--delete-remote` | 否 | bool | 删除云端本地不存在的文件(文件级镜像;**不会**清理远端只有的目录);**必须配合 `--yes`**,且 Validate 阶段会动态检查 `space:document:delete` scope |
| `--yes` | 否 | bool | 确认 `--delete-remote`;不传时该破坏性操作在 Validate 阶段被拒绝 |
## 上传与目录复刻范围
- **只上传 / 覆盖 / 删除 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``POST /open-apis/drive/v1/files/upload_all`,并在 form 中带上现有文件的 `file_token`,由后端原地更新内容并返回新版本号。`items[].version` 字段会回填该版本号。
> **为什么默认是 `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` 仍会正确产出。
## --delete-remote 的安全行为
`--delete-remote` 是命令里**唯一的破坏性 flag**,会按"远端有但本地没有"逐个 `DELETE /open-apis/drive/v1/files/<token>?type=file` 清理云端副本。设计上把它跟 `--yes` 强绑定:
- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。
- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。
- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**stderr 上有提示),命令以非零状态退出,远端不会被破坏。
- 不传 `--delete-remote``summary.deleted_remote` 永远是 0命令对远端"多余"文件视而不见。
- 在线文档docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。
- **远端只有的空目录、本地已删除的目录**也不会被清理 —— 这是"文件级镜像"的语义边界,命令不会对目录结构做主动收敛。
第 6 章里把 `+push --delete-remote` 标了 `high-risk-write`CLI 这边的实现等价于"未传 `--yes` 时拒绝执行 + 动态 scope 预检",符合该约束的精神。
## 输出 schema
```json
{
"summary": {
"uploaded": 0,
"skipped": 0,
"failed": 0,
"deleted_remote": 0
},
"items": [
{"rel_path": "...", "file_token": "...", "action": "folder_created"},
{"rel_path": "...", "file_token": "...", "action": "uploaded", "size_bytes": 0},
{"rel_path": "...", "file_token": "...", "action": "overwritten", "version": "...", "size_bytes": 0},
{"rel_path": "...", "file_token": "...", "action": "skipped", "size_bytes": 0},
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "..."},
{"rel_path": "...", "file_token": "...", "action": "deleted_remote"},
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "..."}
]
}
```
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。
## 性能注意
- 上传流量 ≈ 本地待上传文件的总字节数。push 是**全量**上传 —— 跟 `+status` 不一样,不会按 hash 跳过"内容相同"的文件status 是按 hash 比较push 是按 `--if-exists`),所以一次跑可能很重。
- 想避免重跑全量,可以先 `+status` 找出 `new_local``modified`,再只对这些文件单独上传 / 覆盖。
- 大文件会用三段式分片上传(不会把整个 body 读进内存),但本地磁盘和上行带宽需要够。
## 所需 scope
| 操作 | scope | 是否在命令上预声明 |
|------|-------|-------------------|
| 列出文件夹 / 子目录 | `drive:drive.metadata:readonly` | ✅ 预声明 |
| 上传 / 覆盖文件 | `drive:file:upload` | ✅ 预声明 |
| 新建子目录(`create_folder` | `space:folder:create` | ✅ 预声明 |
| 删除文件(仅 `--delete-remote --yes` | `space:document:delete` | ⚙️ 不在命令默认 Scopes 里,但在 `--delete-remote --yes` 时由 Validate 动态预检 |
`drive:drive` 在部分企业被策略禁用,所以 +push 故意只声明上面这几条细粒度 scope。
> **关于 `space:document:delete`** 框架的 scope 预检(`runner.go: checkShortcutScopes`)会在 `Validate` 和 `--dry-run` 之前就把命令上声明的 scope 全检查一遍;如果把删除 scope 也预声明,**普通上传或 dry-run** 都会因为没授权删除权限而被拦下来。所以这一项不放在命令的默认 Scopes 里,而是在 Validate 中**条件触发**:只有 `--delete-remote --yes` 同时打开时才会调用 `runtime.EnsureScopes([]string{"space:document:delete"})` 做一次动态前置校验。这样既保留了"普通上传不需要删除权限"的便利,又能在真要做镜像删除前把 scope 缺失暴露出来,避免出现"上传成功 → 删除阶段才挂"的半同步状态。
>
> 想一次性把权限补齐:`lark-cli auth login --scope "drive:drive.metadata:readonly drive:file:upload space:folder:create space:document:delete"`。
## 范围限制
`--local-dir` 只接受 cwd 内的相对路径。CLI 会先 `EvalSymlinks` 整条路径,再判断它是否仍落在 cwd 内 —— **指向 cwd 外的符号链接也会被拒**"在 cwd 内放一条软链指向外面" 这条捷径走不通,会直接撞上 `unsafe file path`
如果用户想 push cwd 之外的目录,**不要 agent 自己 `cd` 绕过**。可以选:让用户在外部把 agent 工作目录切换到目标的祖先后重启会话;或者把目标整体物理移动 / 拷贝到 cwd 内(不是软链);或者直接放弃这次同步,改用别的方式。
## 参考
- [lark-drive](../SKILL.md) —— 云空间全部命令
- [lark-shared](../../lark-shared/SKILL.md) —— 认证和全局参数
- [lark-drive-status](lark-drive-status.md) —— 上传前先看差异(避免全量回写)
- [lark-drive-pull](lark-drive-pull.md) —— Drive → 本地的对称命令
- [lark-drive-upload](lark-drive-upload.md) —— 单文件按需上传

View File

@@ -0,0 +1,89 @@
# drive +status
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
按 SHA-256 内容哈希比较本地目录与飞书云空间文件夹,输出四类差异:
| 字段 | 含义 |
|------|------|
| `new_local` | 仅本地存在 |
| `new_remote` | 仅云端存在 |
| `modified` | 双端都存在但 hash 不一致 |
| `unchanged` | 双端都存在且 hash 一致 |
只读命令:流式 hash不下载落盘但双端都有的文件会从云端拉一份字节流过来在内存里算 hash大目录 / 大文件会有可观的网络流量。
## 命令
```bash
# 基础用法 —— 两个必填参数
lark-cli drive +status \
--local-dir ./repo \
--folder-token fldcnxxxxxxxxx
# 只看 hash 不一致的项(结合 --jq 过滤)
lark-cli drive +status \
--local-dir ./repo \
--folder-token fldcnxxxxxxxxx \
--jq '.modified'
```
## 参数
| 标志 | 必填 | 类型 | 说明 |
|------|------|------|------|
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃逸到 cwd 外的相对路径会被 CLI 直接拒绝) |
| `--folder-token` | 是 | string | Drive 文件夹 token |
## 输出 schema
```json
{
"new_local": [{"rel_path": "..."}],
"new_remote": [{"rel_path": "...", "file_token": "..."}],
"modified": [{"rel_path": "...", "file_token": "..."}],
"unchanged": [{"rel_path": "...", "file_token": "..."}]
}
```
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir``--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
## 比较范围
- **只比对 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)都被跳过 —— 它们没有等价的本地二进制可对齐,否则会在 `new_remote` 里产生大量误报。
- 子文件夹会递归遍历rel_path 形如 `sub1/sub2/file.txt`
- 本地侧只比对常规文件regular file符号链接、设备文件等被忽略。
## 范围限制
`+status` 的本地侧只接受 cwd 下的相对路径。如果用户想比对的目录在 cwd 之外,**不要 agent 自己 `cd` 绕过**;让用户在合适的祖先目录重新启动 agent 后再跑。注意:把目标软链接到 cwd 内**也不行**——路径校验会先 `EvalSymlinks` 再判定是否越界,链接最终指向的真实目录如果在 cwd 之外,仍然会被 `unsafe file path` 拒掉。CLI 会在路径越界时直接报错,无需在 skill 这一层提前手动校验。
## 典型用法
把 +status 当作"先看差异、再决定怎么同步"的只读探针。常见接驳场景:
- 想知道云端有什么本地没有的内容 → 看 `new_remote`,按需选择性拉取(`drive +download --file-token <token>`)。
- 想把本地新增的内容推到云端 → 看 `new_local`,再 `drive +upload --file <path> --folder-token <parent>`(注意 +upload 不接受 0 字节文件)。
- 想知道哪些文件在云端被同事改过 → 看 `modified`,逐个 `drive +download` 查内容差异。
## 性能注意
- `unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
- 仅一侧存在的文件不会被下载。
- Hash 计算在内存里流式做io.Copy → sha256.New不会把云端文件落到磁盘。
## 所需 scope
| 操作 | scope |
|------|-------|
| 列出文件夹 / 子目录 | `drive:drive.metadata:readonly` |
| 下载并 hash 文件 | `drive:file:download` |
如果当前 token 缺这些 scope命令会直接报 `missing_scope` 并提示重新登录。`drive:drive` 在部分企业被策略禁用,所以 +status 故意只声明上面这两个细粒度 scope。
## 参考
- [lark-drive](../SKILL.md) —— 云空间全部命令
- [lark-shared](../../lark-shared/SKILL.md) —— 认证和全局参数
- [lark-drive-upload](lark-drive-upload.md) / [lark-drive-download](lark-drive-download.md) —— 把 +status 输出接到推/拉动作上

View File

@@ -5,6 +5,9 @@
上传本地文件到飞书云空间。目标位置可以是 Drive 文件夹,也可以是 wiki 节点。
## 快速决策
- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
## 命令
```bash

View File

@@ -0,0 +1,46 @@
---
name: lark-markdown
version: 1.0.0
description: "飞书 Markdown查看、创建、上传和编辑 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取或修改时使用。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli markdown --help"
---
# markdown (v1)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## 快速决策
- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create`
- 用户要**读取 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +fetch`
- 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite`
- 用户要把本地 Markdown **导入成在线新版文档docx**,不要用本 skill改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间操作,不要留在本 skill切到 [`lark-drive`](../lark-drive/SKILL.md)
## 核心边界
- 本 skill 处理的是 **Drive 中作为普通文件存储的 Markdown**,不是 docx 文档
- `--name` 和本地 `--file` 文件名都必须显式带 `.md` 后缀;不满足时 shortcut 会直接报错
- `--content` 支持:
- 直接传字符串
- `@file` 从本地文件读取内容
- `-` 从 stdin 读取内容
- `--file` 只接受本地 `.md` 文件路径
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli markdown +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive |
| [`+fetch`](references/lark-markdown-fetch.md) | Fetch a Markdown file from Drive |
| [`+overwrite`](references/lark-markdown-overwrite.md) | Overwrite an existing Markdown file in Drive |
## 参考
- [lark-shared](../lark-shared/SKILL.md) — 认证和全局参数
- [lark-drive](../lark-drive/SKILL.md) — Drive 文件管理、导入 docx、move/delete/search 等

View File

@@ -0,0 +1,86 @@
# markdown +create
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
在 Drive 中创建一个原生 Markdown 文件(`.md`)。
## 命令
```bash
# 直接用行内内容创建
lark-cli markdown +create \
--name README.md \
--content '# Hello'
# 从本地 .md 文件创建
lark-cli markdown +create \
--file ./README.md
# 从本地文件读取内容,但仍走 --content
lark-cli markdown +create \
--name README.md \
--content @./README.md
# 从 stdin 读取内容
printf '# Hello\n\nfrom stdin\n' | \
lark-cli markdown +create \
--name README.md \
--content -
# 创建到指定文件夹
lark-cli markdown +create \
--folder-token fldcn_xxx \
--file ./README.md
# 预览底层请求
lark-cli markdown +create \
--name README.md \
--content '# Hello' \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--folder-token` | 否 | 目标 Drive 文件夹 token省略时创建到根目录 |
| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 |
| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file``-`stdin |
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
## 关键约束
- `--content``--file` 必须二选一
- `--name` 必须带 `.md` 后缀
- `--file` 指向的本地文件名也必须带 `.md` 后缀
## 返回值
```json
{
"ok": true,
"identity": "user",
"data": {
"file_token": "boxcnxxxx",
"file_name": "README.md",
"size_bytes": 1234
}
}
```
> [!IMPORTANT]
> 如果 Markdown 文件是**以应用身份bot创建**的,如 `lark-cli markdown +create --as bot`在创建成功后CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。
>
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
> - `status = granted`:当前 CLI 用户已获得该文件的可管理权限
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份bot授予当前用户权限
> - `status = failed`Markdown 文件已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文件
>
> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”。
>
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
## 参考
- [lark-markdown](../SKILL.md) — Markdown 域总览
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,79 @@
# markdown +fetch
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
读取 Drive 中原生 Markdown 文件的内容;也支持把内容保存到本地。
## 命令
```bash
# 直接返回 Markdown 文本
lark-cli markdown +fetch --file-token boxcnxxxx
# 保存到本地
lark-cli markdown +fetch \
--file-token boxcnxxxx \
--output ./README.md
# 传目录时,使用远端文件名保存到该目录下
lark-cli markdown +fetch \
--file-token boxcnxxxx \
--output ./downloads/
# 覆盖已存在文件
lark-cli markdown +fetch \
--file-token boxcnxxxx \
--output ./README.md \
--overwrite
# 预览底层请求
lark-cli markdown +fetch \
--file-token boxcnxxxx \
--output ./README.md \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token` | 是 | 目标 Markdown 文件 token |
| `--output` | 否 | 本地保存路径;既可传具体文件名,也可传目录路径。传目录时使用远端文件名保存;省略时直接返回 Markdown 内容 |
| `--overwrite` | 否 | 覆盖已存在的本地输出文件;仅在传入 `--output` 时生效 |
## 返回值
不传 `--output`
```json
{
"ok": true,
"identity": "user",
"data": {
"file_token": "boxcnxxxx",
"file_name": "README.md",
"content": "# Hello\n",
"size_bytes": 8
}
}
```
传入 `--output`
```json
{
"ok": true,
"identity": "user",
"data": {
"file_token": "boxcnxxxx",
"file_name": "README.md",
"saved_path": "/abs/path/README.md",
"size_bytes": 8
}
}
```
## 参考
- [lark-markdown](../SKILL.md) — Markdown 域总览
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,85 @@
# markdown +overwrite
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
覆盖更新 Drive 中已有的原生 Markdown 文件,并返回覆盖后的新版本号。
## 命令
```bash
# 用行内内容覆盖
lark-cli markdown +overwrite \
--file-token boxcnxxxx \
--content '# Updated'
# 用本地 .md 文件覆盖
lark-cli markdown +overwrite \
--file-token boxcnxxxx \
--file ./README.md
# 覆盖内容时顺便显式指定新文件名
lark-cli markdown +overwrite \
--file-token boxcnxxxx \
--name NEW-README.md \
--content '# Updated'
# 用 --content 从本地文件读取
lark-cli markdown +overwrite \
--file-token boxcnxxxx \
--content @./README.md
# 用 stdin 覆盖
printf '# Updated\n' | \
lark-cli markdown +overwrite \
--file-token boxcnxxxx \
--content -
# 预览底层请求
lark-cli markdown +overwrite \
--file-token boxcnxxxx \
--content '# Updated' \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token` | 是 | 目标 Markdown 文件 token |
| `--name` | 否 | 显式指定覆盖后的文件名;必须带 `.md` 后缀。传入时优先使用它 |
| `--content` | 条件必填 | 新 Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file``-`stdin |
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
## 关键约束
- `--content``--file` 必须二选一
- 如果传了 `--name`,直接使用它作为覆盖后的文件名
- 如果没传 `--name` 且使用 `--content`,默认保留远端原文件名
- 如果没传 `--name` 且使用 `--file`,默认使用本地文件名
- `--file` 指向的本地文件名必须带 `.md` 后缀
- 覆盖成功后 **必须** 返回 `version`
## 返回值
```json
{
"ok": true,
"identity": "user",
"data": {
"file_token": "boxcnxxxx",
"file_name": "README.md",
"version": "7633658129540910621",
"size_bytes": 2048
}
}
```
其中:
- `version` 是覆盖写入后的新版本号
- `size_bytes` 是本次覆盖后的内容大小
## 参考
- [lark-markdown](../SKILL.md) — Markdown 域总览
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物(总结、待办、章节)。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物(总结、待办、章节)5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物。遇到这类请求时,应优先使用本 skill而不是尝试 `ffmpeg``whisper` 等本地转写命令。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
metadata:
requires:
bins: ["lark-cli"]
@@ -51,6 +51,8 @@ metadata:
1. 当用户说"这个妙记的逐字稿""总结""待办""章节"时,**不属于本 skill**。
2. 应使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) 获取对应的纪要产物。
3. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL先提取 `minute_token`
4. 如果用户给的是**本地音视频文件**,但目标是"转成纪要""转成逐字稿""转成文字稿""转成撰写文字",也支持;此时应先按下文第 5 节上传文件生成妙记,再把返回的 `minute_url` 提取成 `minute_token`,继续调用 `vc +notes --minute-tokens`
5. 用户如果直接给出本地文件名或路径,并要求"转逐字稿""转文字稿""整理成撰写文字",这也是本 skill 的明确触发信号。
```bash
# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节)
@@ -59,6 +61,19 @@ lark-cli vc +notes --minute-tokens <minute_token>
> **跨 skill 路由**逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供
### 5. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
1. 当用户需要通过上传本地音视频文件来生成妙记时使用。
2. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。
3. **处理流程**
- **上传音视频获取 `file_token`**:使用 [`lark-cli drive +upload`](../lark-drive/references/lark-drive-upload.md) 上传本地文件到云空间并获取 `file_token`
- **生成妙记**:获取到 `file_token` 后,调用 [`lark-cli minutes +upload`](references/lark-minutes-upload.md) 将文件转换为妙记并获取 `minute_url` 链接。
- **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli vc +notes --minute-tokens`](../lark-vc/references/lark-vc-notes.md) 获取对应产物。
> **注意**:必须先获取飞书云空间的 `file_token` 才能进行转换。
>
> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> vc +notes --minute-tokens`。
## 资源关系
```text
@@ -67,20 +82,22 @@ Minutes (妙记) ← minute_token 标识
└── MediaFile (音频/视频文件) → minutes +download
```
> **能力边界**`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件**。
> **能力边界**`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件、上传音视频生成妙记**。
>
> **路由规则**
>
> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search`
> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill
> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要的是逐字稿、总结、待办、章节,再走 `vc +notes --minute-tokens`
> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要的是逐字稿、文字稿、撰写文字、总结、待办、章节,再走 `vc +notes --minute-tokens`
> - “我的妙记”“参与的妙记”等自然语言映射细则,以 [minutes +search](references/lark-minutes-search.md) 为准
> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果
> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限
> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get`
> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download`
> - 用户说"这个妙记的逐字稿 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
> - 用户说"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload`
> - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens`
## Shortcuts推荐优先使用
@@ -90,9 +107,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`
| -------------------------------------------------- | --------------------------------------------------------------- |
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
| [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute |
- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。
- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。
- 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->

View File

@@ -0,0 +1,104 @@
# minutes +upload
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
上传音视频文件到飞书妙记并生成妙记Minute
本 skill 对应 shortcut`lark-cli minutes +upload`
## 典型触发表达
- "把这个音视频文件转成妙记"
- "把这个音视频文件转成纪要"
- "把这个音视频文件转成逐字稿、文字稿或撰写文字"
- "把这个音视频文件转成总结、待办或章节"
## 完整工作流
当用户要求将音视频文件转换为妙记,或进一步要纪要/逐字稿/文字稿/撰写文字时,必须按照以下步骤执行:
1. **上传文件至云空间获取 file_token**
- 使用 `lark-cli drive +upload` 命令上传本地文件到云空间Drive
```bash
lark-cli drive +upload --file <path/to/media/file>
```
- 从命令的返回结果中提取生成的 `file_token`。
2. **将 file_token 转换为妙记链接minute_url**
- 调用本 shortcut将获取到的 `file_token` 转换为妙记:
```bash
lark-cli minutes +upload --file-token <file_token>
```
- 命令执行成功后,将返回生成的妙记链接 `minute_url`。
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `vc +notes`**
- 从返回的 `minute_url` 中提取路径最后一段,得到 `minute_token`。
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,继续调用:
```bash
lark-cli vc +notes --minute-tokens <minute_token>
```
- `vc +notes --minute-tokens` 会返回纪要文档、逐字稿文档,以及 AI 内置产物(总结、待办、章节);必要时还会把逐字稿落地到本地文件。
> **异步生成提示**API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。
## 命令示例
```bash
# 通过已上传到云空间的 file_token 生成妙记
lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx
# 通过 minute_token 继续获取纪要 / 逐字稿 / 文字稿 / AI 产物
lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token <token>` | 是 | 已经上传到飞书云空间的音视频文件的 file_token |
## 支持的格式与限制
待上传到妙记的原始音视频文件必须满足以下要求:
- 支持音频格式:`wav`、`mp3`、`m4a`、`aac`、`ogg`、`wma`、`amr`
- 支持视频格式:`avi`、`wmv`、`mov`、`mp4`、`m4v`、`mpeg`、`ogg`、`flv`
- 音视频时长不能超过 `6` 小时
- 文件大小不能超过 `6 GB`
> 说明:本 shortcut 只接收 `file_token`,不会直接读取本地文件内容,因此这些格式、时长和大小限制对应的是**原始上传文件**本身。若妙记生成失败,请先回查源文件是否满足上述要求。
## 核心约束
### 1. 必须提供 file_token
本接口不直接处理本地文件的上传,必须先使用 `drive +upload` 将文件上传到云空间获取 `file_token`,然后再调用本接口。
### 2. 先上传,再生成妙记
推荐流程如下:
1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间
2. 从返回结果中取出 `file_token`
3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli vc +notes --minute-tokens <minute_token>`
> **边界说明**`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [vc +notes](../../lark-vc/references/lark-vc-notes.md) 承接。
## 输出结果示例
```json
{
"minute_url": "http(s)://<host>/minutes/<minute-token>"
}
```
| 字段 | 说明 |
|------|------|
| `minute_url` | 生成的妙记访问链接 |
## 参考
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
- [drive +upload](../../lark-drive/references/lark-drive-upload.md) -- 上传文件到云空间
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -49,6 +49,7 @@ lark-cli docs +media-download --type whiteboard --token <whiteboard_token> --out
> - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个
> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有)
> - 用户意图不明确时,应展示所有文档链接让用户选择,而不是替用户决定
> - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +notes` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `vc +notes --minute-tokens`
### 3. 纪要文档与逐字稿链接
1. 纪要文档、逐字稿文档与关联的共享文档默认使用文档 Token 返回。
@@ -90,6 +91,8 @@ Meeting (视频会议)
>
> **妙记边界**`+notes` 负责纪要内容、逐字稿和 AI 产物;妙记基础信息请优先看 [`+recording`](references/lark-vc-recording.md) 与 [lark-minutes](../lark-minutes/SKILL.md)。
>
> **文件转纪要边界**:如果用户给的是本地音视频文件,并希望得到纪要、逐字稿、总结、待办或章节,入口应先走 [lark-minutes](../lark-minutes/SKILL.md) 的上传流程生成 `minute_url` / `minute_token`,再回到 `vc +notes --minute-tokens` 获取内容产物。
>
> **特殊情况**: 当用户查询“今天有哪些会议”时,通过 `vc +search` 查询今天开过的会议记录,同时使用 lark-calendar 技能查询今天还未开始的会议,统一整理后展示给用户。
## Shortcuts推荐优先使用

View File

@@ -1,15 +1,17 @@
# Drive CLI E2E Coverage
## Metrics
- Denominator: 28 leaf commands
- Covered: 1
- Coverage: 3.6%
- Denominator: 29 leaf commands
- Covered: 2
- Coverage: 6.9%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
- Blocked area: live upload, export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
- Blocked area: live upload, live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`, but there is still no live upload workflow coverage.
## Command Table
@@ -20,10 +22,11 @@
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| | drive +export | shortcut | | none | no export workflow yet |
| | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
| ✕ | drive +import | shortcut | | none | no import workflow yet |
| ✕ | drive +move | shortcut | | none | no move workflow yet |
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflow seeds via `+upload` and asserts all four buckets |
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
| ✕ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget (dry-run only) | `--wiki-token`; `parent_type=wiki`; `parent_node` | no live upload workflow yet |
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+export",
"--token", "docxDryRunExport",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "custom-report",
"--output-dir", "./exports",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.token").String(); got != "docxDryRunExport" {
t.Fatalf("body.token=%q, want docxDryRunExport\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.type").String(); got != "docx" {
t.Fatalf("body.type=%q, want docx\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "pdf" {
t.Fatalf("body.file_extension=%q, want pdf\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.file_name").Exists() {
t.Fatalf("file_name should stay local metadata, not export_tasks body\nstdout:\n%s", out)
}
if got := gjson.Get(out, "file_name").String(); got != "custom-report.pdf" {
t.Fatalf("file_name=%q, want custom-report.pdf\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output_dir").String(); got != "./exports" {
t.Fatalf("output_dir=%q, want ./exports\nstdout:\n%s", got, out)
}
}

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_PullDryRun locks in the request shape the +pull shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
// all execute. The printed envelope is then inspected for GET method,
// list-files URL, the folder_token parameter, and key phrases from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any real network call.
func TestDrive_PullDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "list --folder-token") {
t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_PullDryRunRejectsAbsoluteLocalDir confirms the path validator
// runs in the real binary's Validate stage and surfaces a structured error
// referencing --local-dir.
func TestDrive_PullDryRunRejectsAbsoluteLocalDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PullDryRunRejectsDeleteLocalWithoutYes locks in the safety
// guard: --delete-local without --yes must be refused upfront, even under
// --dry-run, so an unintended delete flag never silently slides through.
func TestDrive_PullDryRunRejectsDeleteLocalWithoutYes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--delete-local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("--delete-local without --yes must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--yes") {
t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PullDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_PullDryRunRejectsMissingFolderToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}

View File

@@ -0,0 +1,243 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_PushDryRun locks in the request shape the +push shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
// all execute. The printed envelope is then inspected for GET method,
// list-files URL, the folder_token parameter, and key phrases from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any real network call.
func TestDrive_PushDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "list --folder-token") {
t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out)
}
if !strings.Contains(desc, "upload") {
t.Fatalf("description missing upload phrase, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_PushDryRunRejectsAbsoluteLocalDir confirms the path validator
// runs in the real binary's Validate stage and surfaces a structured error
// referencing --local-dir.
func TestDrive_PushDryRunRejectsAbsoluteLocalDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
// Validate-stage rejection emits ExitValidation (2). A regression
// that reclassified this as a generic api_error (1) or success (0)
// would slip through a loose `!= 0` check, so assert the exact code.
if result.ExitCode != 2 {
t.Fatalf("absolute --local-dir must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes locks in the safety
// guard: --delete-remote without --yes must be refused upfront, even
// under --dry-run, so an unintended delete flag never silently slides
// through.
func TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--delete-remote",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
// Same exact-code reasoning as the absolute-path test: this is a
// Validate-stage rejection so it must surface as ExitValidation (2).
if result.ExitCode != 2 {
t.Fatalf("--delete-remote without --yes must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--yes") {
t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PushDryRunAcceptsDeleteRemoteWithYes is the symmetric guard
// to TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes: when --yes is
// passed alongside --delete-remote, Validate must accept the run and
// hand off to the dry-run renderer.
//
// Specifically pins the conditional scope pre-check added to Validate:
// when the resolver has no token / no scope metadata (the e2e setup
// uses fake credentials with no real auth state), runtime.EnsureScopes
// is a silent no-op so dry-run still emits its envelope. A regression
// where the pre-check incorrectly fired against an empty scope list
// would surface here as a non-zero exit and a missing_scope error.
func TestDrive_PushDryRunAcceptsDeleteRemoteWithYes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--delete-remote",
"--yes",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
// No structured error envelope on stdout/stderr — the conditional
// EnsureScopes call must not trip a missing_scope here.
if strings.Contains(out, `"type": "missing_scope"`) || strings.Contains(result.Stderr, "missing_scope") {
t.Fatalf("conditional scope pre-check fired in a no-credential env\nstdout:\n%s\nstderr:\n%s", out, result.Stderr)
}
}
// TestDrive_PushDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_PushDryRunRejectsMissingFolderToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
// This is a cobra-level required-flag check that fires BEFORE our
// Validate callback, so the exit code is cobra's generic flag-error
// (1) — distinct from ExitValidation (2). Asserting the exact code
// pins which layer rejected the run, which matters because a
// regression that pushed required-flag validation into our own
// Validate (changing the exit class to 2) would silently slip
// through a loose `!= 0` check.
if result.ExitCode != 1 {
t.Fatalf("missing --folder-token must be rejected with exit=1 (cobra required-flag), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_StatusDryRun locks in the request shape the +status shortcut
// emits under --dry-run: the real CLI binary is invoked end-to-end, so the
// full flag-parsing, Validate (which still runs in dry-run mode), and the
// dry-run renderer all execute. The printed envelope is then inspected to
// confirm the GET method, list-files URL, and folder_token parameter, plus
// the descriptive text from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any network call.
func TestDrive_StatusDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
// Validate runs even under --dry-run, so we need a real --local-dir
// inside the working directory; create one in a temp tree.
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "Walk --local-dir") || !strings.Contains(desc, "SHA-256") {
t.Fatalf("description missing key phrases, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_StatusDryRunRejectsAbsoluteLocalDir confirms that the
// --local-dir path validator runs in the real binary's Validate stage and
// surfaces a structured error referencing --local-dir (not the framework
// default --file).
func TestDrive_StatusDryRunRejectsAbsoluteLocalDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_StatusDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_StatusDryRunRejectsMissingFolderToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_StatusWorkflow exercises +status against a real Drive folder so
// the parts that dry-run can't reach — recursive listing pagination, the
// download+hash leg, scope handling, and the SHA-256 comparison itself —
// are covered against the real backend.
//
// Layout:
//
// folder/ (--folder-token target)
// ├── unchanged.txt "match" ↔ local: "match" → unchanged
// ├── modified.txt "remote" ↔ local: "local" → modified
// └── remote-only.txt "remote" ↔ (none) → new_remote
// local/ (--local-dir target)
// ├── unchanged.txt "match"
// ├── modified.txt "local"
// └── local-only.txt "anything" → new_local
//
// Expected output: each of the four buckets contains exactly the file we
// expect, with file_token set for the three buckets that have a Drive side.
func TestDrive_StatusWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-status-" + suffix
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
// Local working directory. +status's --local-dir must be relative to
// the binary's cwd, so each upload + the +status invocation share the
// same WorkDir.
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
// Helper: write a local file under workDir/<rel>.
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
// Helper: stage <content> into a sibling temp file then upload it as
// <name> under folderToken. +upload reads --file relative to its cwd.
uploadDriveFile := func(name, content string) string {
t.Helper()
// Stage outside `local/` so the local-side tree only sees what
// the test wants; +upload still reads relative to workDir.
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
// Seed both sides. Order doesn't matter functionally, but doing the
// uploads first lets the +status listing pick up everything in a
// single pass.
tokUnchanged := uploadDriveFile("unchanged.txt", "match")
tokModified := uploadDriveFile("modified.txt", "remote")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote")
writeLocal("local/unchanged.txt", "match") // matches remote → unchanged
writeLocal("local/modified.txt", "local") // differs → modified
writeLocal("local/local-only.txt", "extra") // only here → new_local
// Run +status against the real folder.
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Assert each bucket contains exactly the file we expect, with the
// correct file_token for sides that have one.
out := result.Stdout
cases := []struct {
bucket string
path string
token string // empty when the bucket has no Drive side
}{
{"unchanged", "unchanged.txt", tokUnchanged},
{"modified", "modified.txt", tokModified},
{"new_local", "local-only.txt", ""},
{"new_remote", "remote-only.txt", tokRemoteOnly},
}
for _, c := range cases {
bucket := gjson.Get(out, "data."+c.bucket)
if !bucket.IsArray() {
t.Fatalf("data.%s must be an array, stdout:\n%s", c.bucket, out)
}
var found bool
bucket.ForEach(func(_, entry gjson.Result) bool {
if entry.Get("rel_path").String() != c.path {
return true // continue
}
found = true
if c.token != "" {
if got := entry.Get("file_token").String(); got != c.token {
t.Errorf("%s entry %q: file_token=%q want %q", c.bucket, c.path, got, c.token)
}
} else if entry.Get("file_token").String() != "" {
t.Errorf("%s entry %q must not carry file_token (local-only), stdout:\n%s", c.bucket, c.path, out)
}
return false // stop
})
if !found {
t.Errorf("%s bucket missing %q\nstdout:\n%s", c.bucket, c.path, out)
}
}
// Make sure each bucket is exactly the size we expect (4 files total,
// no double-bucketing). +upload may attach extra metadata (e.g. a
// folder type entry for `local/` itself) but the lister filters
// type=file so the buckets should be clean.
for _, b := range []struct {
bucket string
want int
}{
{"unchanged", 1},
{"modified", 1},
{"new_local", 1},
{"new_remote", 1},
} {
got := int(gjson.Get(out, "data."+b.bucket+".#").Int())
if got != b.want {
t.Errorf("data.%s length=%d want %d\nstdout:\n%s", b.bucket, got, b.want, out)
}
}
}

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