Compare commits

...

28 Commits

Author SHA1 Message Date
liangshuo-1
88d4e3bd90 chore(release): v1.0.25 (#774)
Change-Id: I9713902d6d7fdfb399e59d8ae23009789a71be3d
2026-05-07 21:19:01 +08:00
MaxHuang22
7c68639b31 fix: remove misleading default value from --as flag help text (#769)
The --as flag displayed (default "bot"), (default "user"), or
(default "auto") in help text, but ResolveAs() never uses the cobra
default — it resolves identity via credential config and auto-detect.
The displayed default misled users into thinking a fixed identity was
used when --as was omitted.

Set cobra default to empty string so no (default ...) suffix appears.
Also remove "auto" from visible options since --as auto is equivalent
to omitting --as entirely.

Change-Id: I51ba550a6697eb3675a29f5cee4d0010e0a1cc16
2026-05-07 16:58:38 +08:00
zgz2048
8b80810fa0 docs: clarify base user open_id guidance (#763)
* docs: clarify base user open_id guidance

* docs: clarify base group chat id guidance
2026-05-07 12:14:03 +08:00
陈家名
eed802c814 fix: handle negative truncate lengths (#744) 2026-05-07 11:40:04 +08:00
niuchong
8f410ab140 feat: add skills version drift notice and unify update flow (#723)
Users who install or upgrade lark-cli via make install, go install, or
direct binary download end up with a binary but no AI agent skills,
degrading agent UX. This PR adds a startup-time skills version drift
notice (injected into JSON envelope _notice.skills, mirroring the
existing _notice.update pattern) and unifies lark-cli update's skills
sync across all three branches (npm / manual / already-latest) with
stamp-based dedup, so any explicit update invocation keeps skills in
sync regardless of how the binary was installed.

Changes:
- new internal/skillscheck package: notice (StaleNotice + atomic
  pending), stamp (~/.lark-cli/skills.stamp), skip (CI / DEV /
  non-release / LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out), check
  (synchronous Init)
- cmd/root.go: rename setupUpdateNotice -> setupNotices, compose
  output.PendingNotice returning {update?, skills?}; capture
  build.Version locally before spawning the async update goroutine
- cmd/update/update.go: add runSkillsAndStamp helper with stamp-based
  dedup; rewire the three branches through shared applySkillsResult /
  emitSkillsTextHints helpers; add skills_status block to --check JSON
  output as a pure report (no side effects)
- internal/update: export IsRelease(version) bool / IsCIEnv() bool
  for cross-package reuse; refresh UpdateInfo.Message to append
  ', run: lark-cli update' so both notices recommend the same fix
- AGENTS.md: add Notification Opt-Outs section documenting
  LARKSUITE_CLI_NO_UPDATE_NOTIFIER and LARKSUITE_CLI_NO_SKILLS_NOTIFIER
- internal/binding/types.go: bump default exec-provider timeout from
  5s to 10s (out-of-scope flake fix for TestResolveExecRef_JSONResponse
  under heavy parallel test load)
2026-05-07 10:52:35 +08:00
陈家名
d9b9f094cf fix: reject invalid json pointer escapes (#741) 2026-05-06 21:54:17 +08:00
Zhang-986
b65147f208 fix: migrate task shortcut errors from bare fmt.Errorf to structured output.Errorf/ErrValidation (#740) 2026-05-06 21:45:37 +08:00
liangshuo-1
c3756f3642 chore(release): v1.0.24 (#761)
Change-Id: I248e14e1d546aa1c49bdb9f443103952488f16d7
2026-05-06 20:35:36 +08:00
liangshuo-1
27a2f2758b fix(config): make agent-binding hints workspace-aware and surface user-identity risks (#728)
AI agents running inside OpenClaw / Hermes were routinely creating a parallel
app via `config init --new` instead of binding to the agent's existing app,
because every "not configured" hint and several deny errors hard-coded
`config init` regardless of workspace. Once bound, the same agents could
silently grant themselves user identity (impersonation) without the user
ever seeing a risk message in chat.

Changes:

- Introduce `core.NotConfiguredError` / `NoActiveProfileError` /
  `reconfigureHint` helpers that branch on `CurrentWorkspace()`. In agent
  workspaces they point at `lark-cli config bind --help` (a help page, not
  a ready-to-run command) so AI must read the binding workflow and confirm
  identity preset with the user before acting. In local terminals they
  preserve the previous `config init --new` guidance.

- Migrate every `config init` hint that should be workspace-aware:
  RequireConfigForProfile, default credential provider, credential provider
  fallback, secret-resolve mismatch, config show, strict-mode entry-point
  errors, default-as, profile use/rename/remove, auth list, doctor's
  config_file check (which now also wraps the OS-level "no such file"
  noise into the user-shaped "not configured" message).

- Refuse `config init` when run inside an OpenClaw / Hermes workspace by
  default; add `--force-init` for the rare case the user genuinely wants
  a parallel app. Without this guard, hint fixes were undone the moment
  AI ignored them.

- Rewrite the strict-mode deny errors in cmd/auth/login.go, cmd/prune.go,
  and internal/cmdutil/factory.go. The previous "AI agents are strictly
  prohibited from modifying this setting" terminated AI reasoning while
  providing no real gate. New errors point at `config strict-mode --help`
  with the legitimate confirmation flow and explicitly note that switching
  does NOT require re-bind. Integration test envelopes updated.

- Tighten `config bind --help` and `config strict-mode --help` to encode
  the user-confirmation discipline directly: identity preset semantics
  (bot-only vs user-default), "DO NOT switch without explicit user
  confirmation", and a cross-reference clarifying that `config bind` is
  for changing the underlying app while `config strict-mode` is the
  policy-only switch (resolves an ambiguity an audit run found).

- Surface user-identity (impersonation) risk at every config write that
  newly grants it, by reusing the canonical IdentityEscalationMessage
  string from bind_messages.go:
  - `noticeUserDefaultRisk` fires on flag-mode bind landing on
    user-default, including the first-time case `warnIdentityEscalation`
    misses (it requires a previous bot lock).
  - `setStrictMode` warns when transitioning bot → user or bot → off
    (newly permits user identity); stays quiet on narrowing changes
    and on off → user (off already permitted user).

- Add tests: notconfigured_test.go (workspace branches),
  init_guard_test.go (refuse + --force-init bypass), bind_warning_test.go
  (user-default warning fires; bot-only does not), strict_mode_warning_test.go
  (5 transitions covering both warn and no-warn paths).

Two follow-ups intentionally deferred: the keychain master-key hint at
internal/keychain/keychain.go:42 still suggests `config init` because the
keychain package can't import core (would be circular); fixing requires
either parameterizing the hint via callback or extracting workspace into
its own package. The lark-shared skill doc still tells AI to run
`config init` for first-time setup; updating the skill is in scope for
a follow-up PR.

Change-Id: I02273e044d9e061d211ceaa4f3ed5a3fb28325b3
2026-05-06 19:27:24 +08:00
JackZhao10086
15ae1fabec fix(auth): handle missing scopes and device flow improvements (#752)
* fix(auth): handle missing scopes and device flow improvements

* fix: remove redundant error return in login scope handler

* test(auth): rename test for zero interval default case

* fix: increase device code polling timeout from 180 to 600 seconds
2026-05-06 17:10:27 +08:00
wittam-01
d317493e49 fix: add url to markdown +create output (#753)
Change-Id: I4fa870415bbad76f721f8aa170180e83fd20281b
2026-05-06 16:03:33 +08:00
zgz2048
a8f078478e docs: refine field update conversion guidance (#748)
* docs: refine field update conversion guidance

* docs: refine field update conversion rules

* docs: adjust field update conversion allowlist
2026-05-06 15:32:38 +08:00
bytedance-zxy
06275415b1 feat(task): add upload task attachment shortcut (#736)
* feat(task): add upload task attachment shortcut

Change-Id: I668bf3d856baa6e35ed982a33c4bf4d03b924f4b

* feat(task): update SKILL.md adding resource_type description

Change-Id: I3ef1aba33ee22e8b03e6f59bc2fb64f55a742270
2026-05-06 14:36:41 +08:00
zgz2048
b4c9c09de0 feat(base): support batch record get and delete (#630)
* feat(base): support batch record get and delete

* fix(base): address batch record PR feedback

* docs(base): refine record skill routing

* refactor(base): use batch record get and delete only

* refactor(base): share record selection normalization

* docs(base): clarify record get field projection help
2026-05-06 14:13:22 +08:00
caojie0621
7fb71c6947 feat(sheets): add sheet management shortcuts (#722)
* feat(sheets): add sheet management shortcuts

- add +create-sheet, +copy-sheet, +delete-sheet, and +update-sheet
- cover request-shape dry-run and sheet workflow tests
- document new sheet management shortcuts in lark-sheets skill

* docs(sheets): consolidate lark-sheets reference docs
2026-05-01 15:49:24 +08:00
河伯
020aeb87ad feat(drive): pre-flight 10000-rune total cap for +add-comment reply_elements (#605)
* feat(drive): pre-flight per-text-element byte limit for +add-comment

The open-platform comment API returns an opaque [1069302] Invalid or
missing parameters whenever a single reply_elements[i] text exceeds
its implicit byte budget. The error does not name which element failed
or that length is the cause, so callers resort to binary-search
debugging.

Empirically: Chinese text up to ~80 chars (~240 bytes) lands; ~130
chars (~390 bytes) fails. Set the pre-flight limit to 300 bytes which
sits safely inside the known-good zone.

- parseCommentReplyElements now rejects any text element whose UTF-8
  byte length exceeds 300, with an ExitError naming the element index
  (#N, 1-based) and both the rune and byte counts, plus an ErrWithHint
  recommending the correct remediation (split into multiple text
  elements — the comment UI renders them as one contiguous comment).
- The previous 1000-rune check is removed: it was too lenient (a
  Chinese text under that cap would still fail server-side).
- skills/lark-drive/references/lark-drive-add-comment.md documents
  the per-element limit and the correct split pattern so agents
  avoid constructing oversized single elements upstream.

Addresses Case 12 in the 踩坑列表 doc.

* fix(drive): correct +add-comment hint to match actual escape coverage

`escapeCommentText` only expands `<` and `>` (each → 4 bytes via
`&lt;` / `&gt;`); `&` is intentionally left as-is. Both the over-limit
hint and the inline comment in `parseCommentReplyElements` previously
claimed `&` was also escaped, with a "4-5 bytes each" range that
implicitly assumed `&amp;` (5 bytes) — a string of 300 `&` chars
would actually fit in the budget, but a user reading the hint would
think otherwise and pre-emptively split it.

Code:
- Hint string ends with `Note: '<' and '>' are HTML-escaped and
  counted in their escaped form (4 bytes each).` (was: included `&`
  and "4-5 bytes")
- Inline comment above the budget check now matches:
  `escapeCommentText only expands '<' and '>' (each becomes 4 bytes:
  &lt; / &gt;); '&' is intentionally left as-is.`

Tests (regression):
- New `300 ampersands accepted (escapeCommentText leaves '&' as-is)`
  subtest pins that 300 `&` chars stay within budget. Without the fix
  this also passed (function was always correct), but the hint was
  lying — the test pins the budget contract loud and clear.
- New `TestParseCommentReplyElementsHintMatchesEscape` asserts the
  hint string itself: must mention `'<' and '>'` / `4 bytes`, must NOT
  mention `'&'` / `&amp;` / `4-5 bytes`. Catches a future drift if
  `escapeCommentText` is changed without updating the hint, or
  vice-versa.

The skill md (`skills/lark-drive/references/lark-drive-add-comment.md`)
already had the right wording (`每个 < 或 > 占 4 字节`), so it was the
in-Go strings that drifted; this commit aligns code with doc.

* fix(drive): rewrite +add-comment length cap to match real server behavior

The original PR set a 300-byte per-element pre-flight check, justified
by the empirical pattern "~80 Chinese chars succeeds, ~130 fails". A
fresh round of probing the live `/open-apis/drive/v1/files/{token}/
new_comments` endpoint with a real docx shows that pattern does not
reproduce, and the actual contract is very different:

  - 10000 ASCII / 10000 Chinese / 10000 '<' (escaped to 40000 bytes)
    in a single text element: all OK
  - 10001 of any of the above in a single text element: [1069302]
  - 5000 + 5000 across two text elements (total 10000): OK
  - 5000 + 5001 across two text elements (total 10001): [1069302]
  - 4000 + 4000 + 4000 across three (total 12000): [1069302]

Two consequences:

1. The cap is *10000 runes total across all reply_elements text*, not
   300 bytes per element. The old check rejected legitimate input
   anywhere from ~100 to 10000 Chinese chars (≈100x too aggressive).

2. The hint that recommended "split the content across multiple
   {\"type\":\"text\",\"text\":\"...\"} elements" was actively wrong —
   splitting doesn't bypass a total cap. A user told to split a
   10001-char message into 5000+5001 hits the same opaque [1069302].

This commit:

- Replaces `maxCommentTextElementBytes = 300` with
  `maxCommentTotalRunes = 10000`. The constant's doc comment records
  the probe matrix above so future maintainers know how it was
  derived.
- Switches the measurement from `len(escapeCommentText(input.Text))`
  to `utf8.RuneCountInString(input.Text)`. Server counts raw runes;
  byte width and post-escape form are irrelevant. The escape itself
  still happens — `<` and `>` still get rendered literally — but it
  no longer participates in the length check.
- Tracks a running `totalRunes` across the whole reply_elements array
  and bails at the first element that pushes the cumulative total
  over the 10000-rune budget, with index reporting that points at the
  offending element.
- Rewrites the over-cap hint to (a) name the actual 10000-rune budget,
  (b) explicitly say splitting does NOT help, (c) drop the wrong
  "comment UI still renders them as one contiguous comment" framing
  that implied splitting was a workaround.
- Adds a `TestParseCommentReplyElementsHintForbidsSplitAdvice`
  watchdog that fails if any future drift puts the discredited split
  advice back into the hint.

Tests: 11 cases on TestParseCommentReplyElementsTextLength covering
single-element boundary (ASCII / Chinese / angle brackets at exactly
10000 and at 10001), multi-element total cap (5000+5000 OK, 5000+5001
rejected with index pointing at element #2), early-element-overshoot
indexing (first element at 10001 reports index #1, not the trailing
element), and mention_user not double-counting toward the cap.

Skill md updated: removes the 300-byte / "split into multiple
elements" advice; documents the 10000-rune total cap with a note that
the schema currently advertises 1-1000 chars and is out of date,
plus a procedure for re-probing if the server-side limit ever moves.

Manual API verification: rebuilt binary and posted comments at
boundary lengths — all OK cases (100 / 5000 / 10000 chars, 5000+5000
split) accepted by server; over-cap cases (10001 / 10100 single, and
5000+5001 split) rejected by the new pre-flight before reaching the
network.

---------

Co-authored-by: fangshuyu <fangshuyu@bytedance.com>
2026-04-30 18:52:44 +08:00
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
276 changed files with 21114 additions and 4724 deletions

View File

@@ -15,6 +15,22 @@ make unit-test # Required before PR (runs with -race)
make test # Full: vet + unit + integration
```
## Notification Opt-Outs
`lark-cli` emits two notice types into JSON envelope `_notice` to nudge AI agents toward fixes:
- `_notice.update` — a newer binary is available on npm
- `_notice.skills` — locally installed skills are out of sync with the running binary
To suppress them in non-CI scripts (CI envs are auto-skipped):
| Env var | Effect |
|---------|--------|
| `LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1` | Suppress `_notice.update` |
| `LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1` | Suppress `_notice.skills` |
Both notices recommend the same fix command: `lark-cli update`. The skills notice's `current` field is `""` when skills have never been synced (cold start) and a version string when synced for an older binary (drift).
## Pre-PR Checks (match CI gates)
1. `make unit-test`

View File

@@ -2,6 +2,60 @@
All notable changes to this project will be documented in this file.
## [v1.0.25] - 2026-05-07
### Features
- Add skills version drift notice and unify update flow (#723)
### Bug Fixes
- Remove misleading default value from `--as` flag help text (#769)
- Handle negative truncate lengths (#744)
- Reject invalid JSON pointer escapes (#741)
- Migrate task shortcut errors to structured `output.Errorf`/`ErrValidation` (#740)
### Documentation
- Clarify base `user_open_id` guidance (#763)
## [v1.0.24] - 2026-05-06
### Features
- **sheets**: Add sheet management shortcuts (#722)
- **base**: Support batch record get and delete (#630)
- **task**: Add upload task attachment shortcut (#736)
- **drive**: Pre-flight 10000-rune total cap for `+add-comment` `reply_elements` (#605)
### Bug Fixes
- **auth**: Handle missing scopes and device flow improvements (#752)
- Add url to markdown `+create` output (#753)
### Documentation
- Refine field update conversion guidance (#748)
## [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 +614,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.25]: https://github.com/larksuite/cli/releases/tag/v1.0.25
[v1.0.24]: https://github.com/larksuite/cli/releases/tag/v1.0.24
[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

@@ -4,6 +4,7 @@
package auth
import (
"errors"
"fmt"
"github.com/spf13/cobra"
@@ -42,7 +43,18 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.")
// auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
}
}
return nil
}

59
cmd/auth/list_test.go Normal file
View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
// `lark-cli auth list` is a read-only probe and must not fail-hard when no
// config exists yet — scripts and AI agents use it as an idempotent "do I
// have any users?" check, so the exit code carries semantic weight. Pair
// that with the existing "configured but no logged-in users" branch (also
// exit 0) and both empty states are consistent.
func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
// Local workspace → hint must mention init, not bind.
out := stderr.String()
if !strings.Contains(out, "config init") {
t.Errorf("local hint missing config init: %s", out)
}
if strings.Contains(out, "config bind") {
t.Errorf("local hint must not mention config bind: %s", out)
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to
// `config bind --help` instead of the local-only `config init`.
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
prev := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should still succeed under agent workspace; got: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "config bind --help") {
t.Errorf("agent hint must point at config bind --help: %s", out)
}
if strings.Contains(out, "config init") {
t.Errorf("agent hint must not mention config init: %s", out)
}
}

View File

@@ -49,10 +49,9 @@ For AI agents: this command blocks until the user completes authorization in the
browser. Run it in the background and retrieve the verification URL from its output.`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, user login is not allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode)
return output.ErrWithHint(output.ExitValidation, "strict_mode",
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}
opts.Ctx = cmd.Context()
if runF != nil {
@@ -243,7 +242,11 @@ func authLoginRun(opts *LoginOptions) error {
return nil
}
// Step 2: Show user code and verification URL
// Step 2: Show user code and verification URL.
// Both branches surface AgentTimeoutHint, but on different channels:
// JSON mode embeds it as a structured field (so an agent that captures
// stdout into a JSON parser sees it without stream-mixing surprises),
// text mode prints to stderr (alongside the URL prompt).
if opts.JSON {
data := map[string]interface{}{
"event": "device_authorization",
@@ -251,6 +254,7 @@ func authLoginRun(opts *LoginOptions) error {
"verification_uri_complete": authResp.VerificationUriComplete,
"user_code": authResp.UserCode,
"expires_in": authResp.ExpiresIn,
"agent_hint": msg.AgentTimeoutHint,
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
@@ -260,6 +264,7 @@ func authLoginRun(opts *LoginOptions) error {
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
// Step 3: Poll for token
@@ -346,9 +351,15 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
// device_code already returned the hint as a JSON field, and writing
// text to stderr would pollute consumers that combine streams via 2>&1.
if !opts.JSON {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
if !result.OK {
if shouldRemoveLoginRequestedScope(result) {

View File

@@ -22,6 +22,7 @@ type loginMsg struct {
// Non-interactive prompts (login.go)
OpenURL string
WaitingAuth string
AgentTimeoutHint string
AuthSuccess string
LoginSuccess string
AuthorizedUser string
@@ -58,6 +59,7 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout ≥ 600s如不支持长 timeout请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询,**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code导致用户授权的链接失效。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
@@ -93,6 +95,7 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is ≥ 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** — each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
@@ -122,5 +125,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

@@ -6,6 +6,7 @@ package auth
import (
"fmt"
"reflect"
"strings"
"testing"
)
@@ -94,3 +95,21 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
}
}
}
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
// auth-login output tells AI agents two things: (a) this command blocks for
// minutes — set a long runner timeout, and (b) the alternative is the
// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and
// kills the process before the user can authorize; without (b) the AI has no
// recovery path and just retries with the same short timeout, invalidating
// each new device code in turn.
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
hint := getLoginMsg(lang).AgentTimeoutHint
for _, want := range []string{"--no-wait", "--device-code"} {
if !strings.Contains(hint, want) {
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
}
}
}
}

View File

@@ -169,7 +169,7 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
if loginSucceeded {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
fmt.Fprintln(f.IOStreams.Out, string(b))
return nil
return output.ErrBare(output.ExitAuth)
}
detail := map[string]interface{}{
"requested": issue.Summary.Requested,
@@ -200,9 +200,6 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
if issue.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
}
if loginSucceeded {
return nil
}
return output.ErrBare(output.ExitAuth)
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/common"
"github.com/zalando/go-keyring"
@@ -371,8 +372,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
}
got := stderr.String()
for _, want := range []string{
@@ -410,8 +415,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
}
var data map[string]interface{}
@@ -616,8 +625,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
Ctx: context.Background(),
Scope: "im:message:send",
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
}
got := stderr.String()
for _, want := range []string{

View File

@@ -62,11 +62,32 @@ func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.
Short: "Bind Agent config to a workspace (source / app-id / force)",
Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace.
For AI agents: pass --source and --app-id to bind non-interactively.
Credentials are synced once; subsequent calls in the Agent's process
context automatically use the bound workspace.`,
Example: ` lark-cli config bind --source openclaw --app-id <id>
lark-cli config bind --source hermes`,
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME); pass it only to override.
For AI agents — DO NOT bind without user confirmation. Binding may
overwrite an existing one and locks in an identity policy. Ask the user:
--identity bot-only bot only (safer default; no impersonation;
cannot access user resources like personal
calendar / mail / drive)
--identity user-default user identity allowed (impersonates the user;
needed for personal-resource access)
Default to bot-only if the user is unsure. Only run the command after
the user confirms both intent and identity preset.
If lark-cli is already bound and the user only wants to change identity
policy on the SAME app, use 'config strict-mode' — that's the policy
switch and does not require re-bind. Use 'config bind' only when the
underlying app itself changes.
Interactive terminal use: run with no flags to enter the TUI form.`,
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
lark-cli config bind --source hermes --identity user-default
# Interactive (terminal user) — TUI prompts for everything:
lark-cli config bind`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.langExplicit = cmd.Flags().Changed("lang")
if runF != nil {
@@ -125,6 +146,7 @@ func configBindRun(opts *BindOptions) error {
return err
}
applyPreferences(appConfig, opts)
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
}
@@ -308,6 +330,23 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
}
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
// flag-mode bind that lands on user-default. The bot-only → user-default
// escalation is already covered by warnIdentityEscalation (errors out before
// applyPreferences runs), and the TUI flow shows IdentityUserDefaultDesc
// during identity selection — so this fires specifically for the case those
// two miss: a fresh flag-mode bind that goes directly to user-default with
// no previous bot lock to escalate from. Without this, AI agents finish such
// a bind with only a "配置成功" message and never relay to the user that the
// AI can now act under their identity.
func noticeUserDefaultRisk(opts *BindOptions) {
if opts.IsTUI || opts.Identity != "user-default" {
return
}
msg := getBindMsg(opts.Lang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
}
// applyPreferences expands the chosen identity preset into the underlying
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
// profile's intent survives later changes to global strict-mode settings.

View File

@@ -377,16 +377,28 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
if err == nil {
t.Fatal("expected error for unbound workspace")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
// Should be a structured ConfigError suggesting config bind, not config init.
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
}
// Hint must point at config bind --help (NOT a ready-to-run bind command):
// AI must read the help and confirm identity preset with the user first.
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point at `config bind --help`; got %q", cfgErr.Hint)
}
if strings.Contains(cfgErr.Hint, "config init") {
t.Errorf("agent hint must not mention config init; got %q", cfgErr.Hint)
}
// Should suggest config bind, not config init
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: "openclaw context detected but lark-cli not bound to openclaw workspace",
Hint: "run: lark-cli config bind --source openclaw",
})
}
// ── Helper function tests (dotenv, brand, path resolution) ──

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
)
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
// with the given identity preset in flag (non-TUI) mode, and returns captured
// stderr. Hermes is the simplest source to fake (single .env file).
func runHermesBindWithIdentity(t *testing.T, identity string) string {
t.Helper()
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(envContent), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Identity: identity,
Lang: "zh",
})
if err != nil {
t.Fatalf("bind failed: %v", err)
}
return stderr.String()
}
// TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation covers the
// gap that previously slipped through: a fresh flag-mode bind landing on
// user-default. warnIdentityEscalation requires a previous bot lock to fire,
// and IdentityUserDefaultDesc only renders in TUI selection — so without
// noticeUserDefaultRisk the user/AI never see the impersonation risk on a
// first-time user-default bind.
func TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation(t *testing.T) {
out := runHermesBindWithIdentity(t, "user-default")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("user-default bind must surface IdentityEscalationMessage; got: %s", out)
}
}
func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
out := runHermesBindWithIdentity(t, "bot-only")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
}
}

View File

@@ -90,15 +90,15 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
if cfgErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
}
}

View File

@@ -20,14 +20,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := core.LoadMultiAppConfig()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return err
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
return core.NoActiveProfileError()
}
if len(args) == 0 {

View File

@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
@@ -33,6 +34,13 @@ type ConfigInitOptions struct {
Lang string
langExplicit bool // true when --lang was explicitly passed
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
// at config bind — which is what AI agents almost always want. Manual
// users with a legitimate need for a separate app can pass --force-init
// to bypass.
ForceInit bool
}
// NewCmdConfigInit creates the config init subcommand.
@@ -46,10 +54,18 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.`,
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default — use 'lark-cli config bind' to bind to the Agent's
existing app instead of creating a parallel one. Pass --force-init only
if the user explicitly wants a separate app inside the Agent workspace.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if err := guardAgentWorkspace(opts); err != nil {
return err
}
if runF != nil {
return runF(opts)
}
@@ -63,10 +79,33 @@ verification URL from its output.`,
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
return cmd
}
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
// Hermes Agent context, because the Agent has already provisioned an app
// and 'config bind' is the right tool for hooking lark-cli into it.
// Running init here would create a parallel app under the agent's workspace
// dir, breaking the binding the user actually wants. --force-init lets a
// human user override when they really do want a separate app.
func guardAgentWorkspace(opts *ConfigInitOptions) error {
if opts.ForceInit {
return nil
}
ws := core.DetectWorkspaceFromEnv(os.Getenv)
if ws.IsLocal() {
return nil
}
return &core.ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
}
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.AppID != "" || o.AppSecretStdin

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
t.Setenv("OPENCLAW_HOME", "")
t.Setenv("OPENCLAW_CLI", "")
t.Setenv("HERMES_HOME", "")
if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil {
t.Errorf("local workspace should allow init, got: %v", err)
}
}
func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
t.Setenv("OPENCLAW_HOME", t.TempDir())
err := guardAgentWorkspace(&ConfigInitOptions{})
if err == nil {
t.Fatal("expected refusal in OpenClaw context, got nil")
}
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
}
if !strings.Contains(cfgErr.Hint, "--force-init") {
t.Errorf("hint must mention --force-init escape hatch; got %q", cfgErr.Hint)
}
}
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
t.Setenv("HERMES_HOME", t.TempDir())
err := guardAgentWorkspace(&ConfigInitOptions{})
if err == nil {
t.Fatal("expected refusal in Hermes context, got nil")
}
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
}
}
func TestGuardAgentWorkspace_ForceInitOverride(t *testing.T) {
t.Setenv("OPENCLAW_HOME", t.TempDir())
// --force-init must let the user proceed even inside an Agent context.
if err := guardAgentWorkspace(&ConfigInitOptions{ForceInit: true}); err != nil {
t.Errorf("--force-init should bypass the guard, got: %v", err)
}
}

View File

@@ -44,12 +44,12 @@ func configShowRun(opts *ConfigShowOptions) error {
config, err := core.LoadMultiAppConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return notConfiguredError()
return core.NotConfiguredError()
}
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
}
if config == nil || len(config.Apps) == 0 {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return core.NotConfiguredError()
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
@@ -75,18 +75,3 @@ func configShowRun(opts *ConfigShowOptions) error {
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
return nil
}
// notConfiguredError returns the "not configured" error with a hint that
// points the user to the right next step: config init for the default local
// workspace, config bind for an Agent workspace that has not been bound yet.
func notConfiguredError() error {
ws := core.CurrentWorkspace()
if ws.IsLocal() {
return output.ErrWithHint(output.ExitValidation, "config",
"not configured",
"run: lark-cli config init")
}
return output.ErrWithHint(output.ExitValidation, ws.Display(),
fmt.Sprintf("%s context detected but lark-cli not bound to %s workspace", ws.Display(), ws.Display()),
fmt.Sprintf("run: lark-cli config bind --source %s", ws.Display()))
}

View File

@@ -21,44 +21,44 @@ func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "strict-mode [bot|user|off]",
Short: "View or set strict mode (identity restriction policy)",
Long: `View or set strict mode (identity restriction policy).
Long: `View or set strict mode — the identity restriction policy.
Without arguments, shows the current strict mode status and its source.
Pass "bot", "user", or "off" to set strict mode.
Use --global to set at the global level.
Use --reset to clear the profile-level setting (inherit global).
bot only bot identity allowed (user commands hidden)
user only user identity allowed (bot commands hidden)
off no restriction (default)
Modes:
bot — only bot identity is allowed, user commands are hidden
user — only user identity is allowed, bot commands are hidden
off — no restriction (default)
No args: show current mode. Switching does NOT require re-bind.
WARNING: Strict mode is a security policy set by the administrator.
AI agents are strictly prohibited from modifying this setting.`,
For AI agents: this is a security policy. DO NOT switch without
explicit user confirmation — never run on your own initiative.`,
Example: ` lark-cli config strict-mode # show current
lark-cli config strict-mode user # switch (after user confirms)
lark-cli config strict-mode bot --global # set globally
lark-cli config strict-mode --reset # clear profile override`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := core.LoadMultiAppConfig()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return err
}
if reset {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
return core.NoActiveProfileError()
}
return resetStrictMode(f, multi, app, global, args)
}
if len(args) == 0 {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
return core.NoActiveProfileError()
}
return showStrictMode(cmd.Context(), f, multi, app)
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if !global && app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
return core.NoActiveProfileError()
}
return setStrictMode(f, multi, app, args[0], global)
},
@@ -106,6 +106,24 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
}
// Capture the old mode at the SAME scope being changed, so we can warn
// only when the policy actually expands user-identity at that scope.
// --global → compare raw multi.StrictMode (profiles with explicit
// overrides are unaffected; their warning comes from the existing
// "profile %q has strict-mode explicitly set" notice below).
// profile → compare effective mode (override > global > default), so
// a profile flipping from inherited bot to explicit off still warns.
// The previous version always used the profile's effective mode, which
// false-positived (--global change while current profile has an explicit
// override) and false-negatived (--global broadening that doesn't affect
// the current profile but does affect other inheriting profiles).
var oldMode core.StrictMode
if global {
oldMode = multi.StrictMode
} else {
oldMode, _ = resolveStrictModeStatus(multi, app)
}
if global {
multi.StrictMode = mode
for _, a := range multi.Apps {
@@ -119,7 +137,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
}
} else {
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
return core.NoActiveProfileError()
}
app.StrictMode = &mode
}
@@ -127,6 +145,11 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
}
scope := "profile"
if global {
scope = "global"
@@ -135,6 +158,16 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
return nil
}
// strictModeRelaxLang picks the bind-message bundle whose language matches the
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
// available (global mutation with no current app).
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
if app != nil {
return getBindMsg(app.Lang)
}
return getBindMsg("")
}
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
if app != nil && app.StrictMode != nil {
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
// returns the captured stderr — that's where success-path messages and the
// new user-identity warning land.
func runStrictMode(t *testing.T, args ...string) string {
t.Helper()
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("strict-mode %v failed: %v", args, err)
}
return stderr.String()
}
// expandsUserIdentity covers the only two transitions where AI gains the
// ability to act under the user's identity, and asserts the warning fires.
// Reuses bind_messages.go's IdentityEscalationMessage as the canonical text
// so all three call sites (bind upgrade, fresh user-default bind, strict-mode
// relax) stay phrased identically.
func TestStrictMode_BotToUser_WarnsAboutIdentityRisk(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot")
out := runStrictMode(t, "user")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("bot→user transition must surface IdentityEscalationMessage; got: %s", out)
}
}
func TestStrictMode_BotToOff_WarnsAboutIdentityRisk(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot")
out := runStrictMode(t, "off")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("bot→off transition must surface IdentityEscalationMessage; got: %s", out)
}
}
// narrowingDoesNotWarn covers the cases that revoke or keep user-identity
// scope — those should stay quiet, otherwise AI will spam users with risk
// text on every restrictive change.
func TestStrictMode_UserToBot_NoWarning(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "user")
out := runStrictMode(t, "bot")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("user→bot is a narrowing change; must not warn. got: %s", out)
}
}
func TestStrictMode_OffToBot_NoWarning(t *testing.T) {
setupStrictModeTestConfig(t)
// Default starts at off; explicitly set bot — narrowing.
out := runStrictMode(t, "bot")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("off→bot is a narrowing change; must not warn. got: %s", out)
}
}
func TestStrictMode_OffToUser_NoWarning(t *testing.T) {
// Off already permits user-identity, so off→user is not a NEW grant
// even though it forces user identity. Don't warn.
setupStrictModeTestConfig(t)
out := runStrictMode(t, "user")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("off→user does not newly permit user identity; must not warn. got: %s", out)
}
}
// --- --global path: comparison must use multi.StrictMode, not profile's
// effective mode. The previous (buggy) version used resolveStrictModeStatus
// here too, leading to both false positives (current profile has explicit
// override unaffected by --global → still warned) and false negatives
// (current profile has explicit override that masks an actual bot → off
// global broadening for OTHER inheriting profiles → didn't warn).
func TestStrictMode_GlobalBotToUser_Warns(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot", "--global")
out := runStrictMode(t, "user", "--global")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("global bot→user must warn (broadens user-identity for inheriting profiles); got: %s", out)
}
}
func TestStrictMode_GlobalBotToOff_Warns(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot", "--global")
out := runStrictMode(t, "off", "--global")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("global bot→off must warn (newly permits user identity in inheriting profiles); got: %s", out)
}
}
// FalsePositive: current profile has explicit "bot" override, global goes
// off → user. The current profile is unaffected (still bot via override),
// and off→user at the global level is not a new grant either. Must not warn.
func TestStrictMode_GlobalOffToUser_WithProfileBotOverride_NoWarning(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot") // profile-level explicit bot
runStrictMode(t, "off", "--global") // global = off
out := runStrictMode(t, "user", "--global")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("global off→user with profile-bot-override must not warn (profile unaffected, global wasn't bot); got: %s", out)
}
}
// FalseNegative: global = bot, current profile has explicit "off" override.
// Running --global off broadens OTHER inheriting profiles (bot → off). The
// current profile doesn't change effective mode, but the policy still expanded
// user-identity, so warning must fire. The pre-fix logic compared via the
// current profile's effective mode and missed this case.
func TestStrictMode_GlobalBotToOff_WithProfileOffOverride_Warns(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot", "--global") // global = bot
runStrictMode(t, "off") // profile-level explicit off (already shows the warning at profile scope)
out := runStrictMode(t, "off", "--global")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("global bot→off must warn even when current profile has explicit off (other profiles inherit and newly permit user identity); got: %s", out)
}
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"net/http"
"os"
"sync"
"time"
@@ -83,7 +84,20 @@ func doctorRun(opts *DoctorOptions) error {
// ── 1. Config file ──
_, err := core.LoadMultiAppConfig()
if err != nil {
checks = append(checks, fail("config_file", err.Error(), "run: lark-cli config init"))
// For "config not present" cases, prefer the workspace-aware
// NotConfiguredError message + hint (e.g. "openclaw context
// detected but lark-cli is not bound to it" → bind --help) over
// the OS-level "open ... no such file or directory".
// For other errors (parse, perms), keep the raw error so the
// underlying problem is still visible.
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
}
checks = append(checks, fail("config_file", msg, hint))
return finishDoctor(f, checks)
}
checks = append(checks, pass("config_file", "config.json found"))

View File

@@ -32,9 +32,9 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
}
func profileRemoveRun(f *cmdutil.Factory, name string) error {
multi, err := core.LoadMultiAppConfig()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return err
}
idx := multi.FindAppIndex(name)

View File

@@ -32,9 +32,9 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
return output.ErrValidation("%v", err)
}
multi, err := core.LoadMultiAppConfig()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return err
}
idx := multi.FindAppIndex(oldName)

View File

@@ -31,9 +31,9 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
}
func profileUseRun(f *cmdutil.Factory, name string) error {
multi, err := core.LoadMultiAppConfig()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return err
}
// Handle "-" for toggle-back

View File

@@ -4,6 +4,7 @@
package cmd
import (
"fmt"
"slices"
"github.com/larksuite/cli/internal/cmdutil"
@@ -48,10 +49,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
Hidden: true,
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, only %s identity is allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode, mode.ForcedIdentity())
return output.ErrWithHint(output.ExitValidation, "strict_mode",
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
},
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
@@ -47,7 +48,7 @@ EXAMPLES:
FLAGS:
--params <json> URL/query parameters JSON
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
--as <type> identity type: user | bot | auto (default: auto)
--as <type> identity type: user | bot
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
--page-all automatically paginate through all pages
--page-size <N> page size (0 = use API default)
@@ -93,9 +94,9 @@ func Execute() int {
HideProfile(isSingleAppMode()),
)
// --- Update check (non-blocking) ---
// --- Notices (non-blocking) ---
if !isCompletionCommand(os.Args) {
setupUpdateNotice()
setupNotices()
}
if err := rootCmd.Execute(); err != nil {
@@ -104,42 +105,54 @@ func Execute() int {
return 0
}
// setupUpdateNotice starts an async update check and wires the output decorator.
func setupUpdateNotice() {
// Sync: check cache immediately (no network, fast).
// setupNotices wires both the binary update notice and the skills
// staleness notice into output.PendingNotice as a composed function.
// Each provider populates an independent key under _notice; either
// or both may be present in any given envelope.
func setupNotices() {
// Binary update — synchronous cache check + async refresh
if info := update.CheckCached(build.Version); info != nil {
update.SetPending(info)
}
// Async: refresh cache for this run (and future runs).
ver := build.Version
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
}
}()
update.RefreshCache(build.Version)
// If cache was just populated for the first time, set pending now.
update.RefreshCache(ver)
if update.GetPending() == nil {
if info := update.CheckCached(build.Version); info != nil {
if info := update.CheckCached(ver); info != nil {
update.SetPending(info)
}
}
}()
// Wire the output decorator so JSON envelopes include "_notice".
// Skills check — synchronous, local-only (no network, no goroutine).
skillscheck.Init(build.Version)
// Composed notice provider — emits keys only when each pending is set.
output.PendingNotice = func() map[string]interface{} {
info := update.GetPending()
if info == nil {
return nil
}
return map[string]interface{}{
"update": map[string]interface{}{
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
},
}
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
}
}
if len(notice) == 0 {
return nil
}
return notice
}
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"os"
"reflect"
"strings"
"testing"
@@ -14,11 +15,14 @@ import (
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -343,11 +347,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
"auth", "login", "--json", "--scope", "im:message.send_as_user",
})
// auth login is user-only, so it gets pruned in strict-mode-bot and the
// stub error fires (not login.go's inline check, which is shadowed by
// pruning).
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -364,7 +372,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -401,7 +410,8 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
Identity: "bot",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -419,7 +429,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -436,7 +447,8 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -454,7 +466,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -490,3 +503,181 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
},
})
}
// TestSetupNotices_ColdStart verifies that when no skills stamp exists,
// the composed PendingNotice provider includes a "skills" key with an
// empty Current and the cold-start message.
func TestSetupNotices_ColdStart(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
origVersion := build.Version
build.Version = "1.0.21"
t.Cleanup(func() { build.Version = origVersion })
// Reset pending state to ensure a clean test.
skillscheck.SetPending(nil)
update.SetPending(nil)
output.PendingNotice = nil
t.Cleanup(func() {
skillscheck.SetPending(nil)
update.SetPending(nil)
output.PendingNotice = nil
})
setupNotices()
notice := output.GetNotice()
if notice == nil {
t.Fatal("GetNotice() = nil, want non-nil for cold start")
}
skills, ok := notice["skills"].(map[string]interface{})
if !ok {
t.Fatalf("notice.skills missing, got %+v", notice)
}
if skills["current"] != "" || skills["target"] != "1.0.21" {
t.Errorf("notice.skills = %+v, want {current:\"\", target:\"1.0.21\"}", skills)
}
if msg, _ := skills["message"].(string); msg != "lark-cli skills not installed, run: lark-cli update" {
t.Errorf("notice.skills.message = %q, want cold-start message", msg)
}
}
// TestSetupNotices_InSync verifies that a matching stamp produces no
// skills key in the composed notice.
func TestSetupNotices_InSync(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
origVersion := build.Version
build.Version = "1.0.21"
t.Cleanup(func() { build.Version = origVersion })
skillscheck.SetPending(nil)
update.SetPending(nil)
output.PendingNotice = nil
t.Cleanup(func() {
skillscheck.SetPending(nil)
update.SetPending(nil)
output.PendingNotice = nil
})
setupNotices()
notice := output.GetNotice()
if notice != nil {
if _, ok := notice["skills"]; ok {
t.Errorf("notice.skills present in in-sync state: %+v", notice)
}
}
}
// TestSetupNotices_Drift verifies a mismatching stamp produces the
// drift message with both current and target populated.
func TestSetupNotices_Drift(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
origVersion := build.Version
build.Version = "1.0.21"
t.Cleanup(func() { build.Version = origVersion })
skillscheck.SetPending(nil)
update.SetPending(nil)
output.PendingNotice = nil
t.Cleanup(func() {
skillscheck.SetPending(nil)
update.SetPending(nil)
output.PendingNotice = nil
})
setupNotices()
notice := output.GetNotice()
if notice == nil {
t.Fatal("GetNotice() = nil, want non-nil for drift")
}
skills, ok := notice["skills"].(map[string]interface{})
if !ok {
t.Fatalf("notice.skills missing, got %+v", notice)
}
if skills["current"] != "1.0.20" || skills["target"] != "1.0.21" {
t.Errorf("notice.skills = %+v, want {current:\"1.0.20\", target:\"1.0.21\"}", skills)
}
want := "lark-cli skills 1.0.20 out of sync with binary 1.0.21, run: lark-cli update"
if msg, _ := skills["message"].(string); msg != want {
t.Errorf("notice.skills.message = %q, want %q", msg, want)
}
}
// TestSetupNotices_BothUpdateAndSkills verifies the composed envelope
// emits BOTH "_notice.update" and "_notice.skills" keys when each
// pending value is set. Drives the skills key via setupNotices() (drift
// state) and manually populates the update pending afterwards, since
// clearNoticeEnv suppresses the update goroutine to avoid network
// flakiness.
func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
origVersion := build.Version
build.Version = "1.0.21"
t.Cleanup(func() { build.Version = origVersion })
skillscheck.SetPending(nil)
update.SetPending(nil)
output.PendingNotice = nil
t.Cleanup(func() {
skillscheck.SetPending(nil)
update.SetPending(nil)
output.PendingNotice = nil
})
setupNotices()
// After setupNotices, skills pending is set (drift). Manually populate
// the update side so the composed envelope has both keys — the update
// goroutine is suppressed by clearNoticeEnv.
update.SetPending(&update.UpdateInfo{Current: "1.0.21", Latest: "1.0.22"})
notice := output.GetNotice()
if notice == nil {
t.Fatal("GetNotice() = nil, want both keys")
}
if _, ok := notice["update"].(map[string]interface{}); !ok {
t.Errorf("missing 'update' key: %+v", notice)
}
if _, ok := notice["skills"].(map[string]interface{}); !ok {
t.Errorf("missing 'skills' key: %+v", notice)
}
}
// clearNoticeEnv unsets the env vars that affect either notice. We
// proactively SUPPRESS the update notifier (LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1)
// because setupNotices spawns a goroutine that hits the npm registry —
// tests focused on the skills check should not depend on network state.
func clearNoticeEnv(t *testing.T) {
t.Helper()
for _, key := range []string{
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER",
"CI", "BUILD_NUMBER", "RUN_ID",
} {
t.Setenv(key, "")
os.Unsetenv(key)
}
// Suppress the update goroutine's network call deterministically.
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
}

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

@@ -14,13 +14,15 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
)
const (
repoURL = "https://github.com/larksuite/cli"
maxNpmOutput = 2000
osWindows = "windows"
repoURL = "https://github.com/larksuite/cli"
maxNpmOutput = 2000
maxStderrDetail = 500
osWindows = "windows"
)
// Overridable for testing.
@@ -33,6 +35,13 @@ var (
func isWindows() bool { return currentOS == osWindows }
// normalizeVersion canonicalizes a version string for stamp comparison.
// Strips a leading "v" so versions written from Makefile (git describe →
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
func normalizeVersion(s string) string {
return strings.TrimPrefix(strings.TrimSpace(s), "v")
}
func releaseURL(version string) string {
return repoURL + "/releases/tag/v" + strings.TrimPrefix(version, "v")
}
@@ -127,16 +136,15 @@ func updateRun(opts *UpdateOptions) error {
// 3. Compare versions
if !opts.Force && !update.IsNewer(latest, cur) {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "already_up_to_date",
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
})
return nil
// Run skills sync before returning — covers the case where the
// binary is already current but skills were never synced.
// Stamp dedup makes this a no-op if skills are already in sync.
// Skip side-effects under --check (pure report path per spec §3.6).
var skillsResult *selfupdate.NpmResult
if !opts.Check {
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
}
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
return nil
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
// 4. Detect installation method
@@ -149,7 +157,7 @@ func updateRun(opts *UpdateOptions) error {
// 6. Execute update
if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect)
return doManualUpdate(opts, io, cur, latest, detect, updater)
}
return doNpmUpdate(opts, io, cur, latest, updater)
}
@@ -169,13 +177,24 @@ func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errTy
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
out := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "update_available",
"auto_update": canAutoUpdate,
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
"url": releaseURL(latest), "changelog": changelogURL(),
})
}
// skills_status: pure report, no side effect, no stamp write.
// ReadStamp errors are silently swallowed — if we can't read the
// stamp we just omit the block rather than fail the --check.
if stamp, err := skillscheck.ReadStamp(); err == nil {
out["skills_status"] = map[string]interface{}{
"current": stamp,
"target": cur,
"in_sync": stamp == cur,
}
}
output.PrintJson(io.Out, out)
return nil
}
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
@@ -189,15 +208,19 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
return nil
}
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult) error {
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
reason := detect.ManualReason()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
out := map[string]interface{}{
"ok": true, "previous_version": cur, "latest_version": latest,
"action": "manual_required",
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
"url": releaseURL(latest), "changelog": changelogURL(),
})
}
applySkillsResult(out, skillsResult)
output.PrintJson(io.Out, out)
return nil
}
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
@@ -205,7 +228,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
fmt.Fprintf(io.ErrOut, "\nAfter updating, also update skills:\n npx -y skills add larksuite/cli -g -y\n")
emitSkillsTextHints(io, skillsResult)
return nil
}
@@ -264,8 +287,10 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
return output.ErrBare(output.ExitAPI)
}
// Skills update (best-effort).
skillsResult := updater.RunSkillsUpdate()
// Skills update (best-effort) — uses runSkillsAndStamp so the
// stamp gets persisted on success and dedup applies if a previous
// run already stamped this version.
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
if opts.JSON {
result := map[string]interface{}{
@@ -274,28 +299,17 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
if skillsResult.Err != nil {
result["skills_warning"] = fmt.Sprintf("skills update failed: %s", skillsResult.Err)
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
result["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
}
}
applySkillsResult(result, skillsResult)
output.PrintJson(io.Out, result)
return nil
}
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
if skillsResult.Err != nil {
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %s\n", symWarn(), skillsResult.Err)
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, 500))
}
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
} else {
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
if skillsResult != nil {
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
}
emitSkillsTextHints(io, skillsResult)
return nil
}
@@ -312,3 +326,96 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
// stamp on success. Skips the npx invocation when the stamp already
// matches stampVersion (unless force is true). The stamp write failure
// emits a warning to io.ErrOut but does NOT fail the update command —
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
// dedup; otherwise returns the underlying *NpmResult with Err semantics
// from RunSkillsUpdate.
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
if !force {
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
return nil
}
}
r := updater.RunSkillsUpdate()
if r.Err == nil {
if err := skillscheck.WriteStamp(stampVersion); err != nil {
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
}
}
return r
}
// reportAlreadyUpToDate emits the JSON / pretty output for the
// already-up-to-date branch, including any skills_action / skills_warning
// fields derived from skillsResult. When check is true, this is the pure
// report path (spec §3.6): no side-effects, JSON envelope uses
// skills_status (spec §4.2) instead of skills_action.
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
if opts.JSON {
out := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "already_up_to_date",
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
}
if check {
// Pure report — read stamp directly, emit skills_status block.
// ReadStamp errors are silently swallowed — if we can't read
// the stamp we just omit the block rather than fail the --check.
if stamp, err := skillscheck.ReadStamp(); err == nil {
out["skills_status"] = map[string]interface{}{
"current": stamp,
"target": cur,
"in_sync": stamp == cur,
}
}
} else {
applySkillsResult(out, skillsResult)
}
output.PrintJson(io.Out, out)
return nil
}
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
if !check {
emitSkillsTextHints(io, skillsResult)
}
return nil
}
// applySkillsResult mutates the JSON envelope to include skills_action
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
switch {
case r == nil:
env["skills_action"] = "in_sync"
case r.Err != nil:
env["skills_action"] = "failed"
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
}
default:
env["skills_action"] = "synced"
}
}
// emitSkillsTextHints prints human-readable feedback about the skills
// sync result for non-JSON output.
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
switch {
case r == nil:
// dedup hit — silent (already up to date)
case r.Err != nil:
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
}
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
default:
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
}
}

View File

@@ -5,8 +5,11 @@ package cmdupdate
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -14,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
)
// newTestFactory creates a test factory with minimal config.
@@ -709,6 +713,7 @@ func TestUpdateWindows_Symbols(t *testing.T) {
}
func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -737,6 +742,7 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
}
func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -789,6 +795,7 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -836,6 +843,98 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
}
}
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
// for direct calls to internals like runSkillsAndStamp that write to
// io.ErrOut.
func newTestIO() *cmdutil.IOStreams {
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
}
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
called = true
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
if got != nil {
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
}
if called {
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
}
}
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
called = true
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
if got == nil {
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
}
if !called {
t.Error("SkillsUpdateOverride not called with force=true")
}
}
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
}
}
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
return r
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.20" {
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
}
}
func TestTruncate(t *testing.T) {
long := strings.Repeat("x", 3000)
got := selfupdate.Truncate(long, 2000)
@@ -849,3 +948,272 @@ func TestTruncate(t *testing.T) {
t.Errorf("expected 'hello', got %q", got2)
}
}
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
origFetch := fetchLatest
origCur := currentVersion
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur })
fetchLatest = func() (string, error) { return "1.0.21", nil }
currentVersion = func() string { return "1.0.21" }
skillsCalled := false
origNew := newUpdater
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
},
}
}
f, _, _ := newTestFactory(t)
opts := &UpdateOptions{Factory: f, JSON: true}
if err := updateRun(opts); err != nil {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
}
}
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
origFetch := fetchLatest
origCur := currentVersion
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur })
fetchLatest = func() (string, error) { return "1.0.22", nil }
currentVersion = func() string { return "1.0.21" }
skillsCalled := false
origNew := newUpdater
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallManual,
ResolvedPath: "/usr/local/bin/lark-cli",
}
},
SkillsUpdateOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
},
}
}
f, _, _ := newTestFactory(t)
opts := &UpdateOptions{Factory: f, JSON: true}
if err := updateRun(opts); err != nil {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in manual branch, want called")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
}
}
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
origFetch := fetchLatest
origCur := currentVersion
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur })
fetchLatest = func() (string, error) { return "1.0.22", nil }
currentVersion = func() string { return "1.0.21" }
skillsCalled := false
origNew := newUpdater
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallNpm, NpmAvailable: true,
ResolvedPath: "/usr/local/bin/lark-cli",
}
},
NpmInstallOverride: func(version string) *selfupdate.NpmResult {
return &selfupdate.NpmResult{}
},
VerifyOverride: func(expectedVersion string) error { return nil },
SkillsUpdateOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
},
}
}
f, _, _ := newTestFactory(t)
opts := &UpdateOptions{Factory: f, JSON: true}
if err := updateRun(opts); err != nil {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in npm branch")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.22" {
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
}
}
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
origFetch := fetchLatest
origCur := currentVersion
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur })
fetchLatest = func() (string, error) { return "1.0.22", nil }
currentVersion = func() string { return "1.0.21" }
origNew := newUpdater
t.Cleanup(func() { newUpdater = origNew })
skillsCalled := false
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsUpdateOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
},
}
}
f, stdout, _ := newTestFactory(t)
opts := &UpdateOptions{Factory: f, JSON: true, Check: true}
if err := updateRun(opts); err != nil {
t.Fatalf("updateRun(--check) err = %v, want nil", err)
}
if skillsCalled {
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
}
status, ok := env["skills_status"].(map[string]interface{})
if !ok {
t.Fatalf("skills_status missing or wrong type in --check JSON: %s", stdout.String())
}
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
}
}
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
origFetch := fetchLatest
origCur := currentVersion
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur })
fetchLatest = func() (string, error) { return "1.0.21", nil }
currentVersion = func() string { return "1.0.21" }
skillsCalled := false
origNew := newUpdater
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
},
}
}
f, stdout, _ := newTestFactory(t)
opts := &UpdateOptions{Factory: f, JSON: true, Check: true}
if err := updateRun(opts); err != nil {
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
}
if skillsCalled {
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.20" {
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal stdout: %v\n%s", err, stdout.String())
}
if env["action"] != "already_up_to_date" {
t.Errorf("action = %v, want \"already_up_to_date\"", env["action"])
}
if _, has := env["skills_action"]; has {
t.Errorf("skills_action present under --check, want absent: %+v", env)
}
status, ok := env["skills_status"].(map[string]interface{})
if !ok {
t.Fatalf("skills_status missing under --check + already-latest: %s", stdout.String())
}
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
}
}
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
// Force WriteStamp to fail by pointing config dir at a path that exists
// as a regular file (so MkdirAll fails).
tmp := t.TempDir()
badPath := filepath.Join(tmp, "blocker")
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
f, _, stderr := newTestFactory(t)
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
return &selfupdate.NpmResult{} // success
},
}
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
}
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
t.Errorf("stderr does not contain warning: %q", stderr.String())
}
}
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
// message is printed to ErrOut on a successful (Err == nil) result.
func TestEmitSkillsTextHints_Success(t *testing.T) {
f, _, stderr := newTestFactory(t)
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
if !strings.Contains(stderr.String(), "Skills updated") {
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
}
}

View File

@@ -142,8 +142,12 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
errOut = io.Discard
}
if interval < 1 {
interval = 5
}
const maxPollInterval = 60
const maxPollAttempts = 200
const maxPollAttempts = 600
endpoints := ResolveOAuthEndpoints(brand)
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)

View File

@@ -5,10 +5,12 @@ package auth
import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
@@ -17,6 +19,12 @@ import (
"github.com/larksuite/cli/internal/keychain"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
ep := ResolveOAuthEndpoints(core.BrandFeishu)
@@ -172,3 +180,33 @@ func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
t.Fatalf("expected truncated cmdline in log, got %q", got)
}
}
func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
t.Parallel()
var requests atomic.Int32
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: http.NoBody,
}, nil
}),
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
t.Cleanup(cancel)
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
if result == nil {
t.Fatal("PollDeviceToken() returned nil result")
}
if result.Message != "Polling was cancelled" {
t.Fatalf("PollDeviceToken() message = %q, want polling cancellation", result.Message)
}
if got := requests.Load(); got != 0 {
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
}
}

View File

@@ -33,8 +33,10 @@ func ReadJSONPointer(data interface{}, pointer string) (interface{}, error) {
for i, raw := range segments {
// RFC 6901 unescaping: ~1 → /, ~0 → ~ (order matters).
key := strings.ReplaceAll(raw, "~1", "/")
key = strings.ReplaceAll(key, "~0", "~")
key, err := decodeJSONPointerSegment(raw)
if err != nil {
return nil, fmt.Errorf("json pointer %q: segment %q: %w", pointer, raw, err)
}
m, ok := current.(map[string]interface{})
if !ok {
@@ -53,3 +55,26 @@ func ReadJSONPointer(data interface{}, pointer string) (interface{}, error) {
return current, nil
}
func decodeJSONPointerSegment(raw string) (string, error) {
var out strings.Builder
for i := 0; i < len(raw); i++ {
if raw[i] != '~' {
out.WriteByte(raw[i])
continue
}
if i+1 >= len(raw) {
return "", fmt.Errorf("invalid escape: ~ must be followed by 0 or 1")
}
switch raw[i+1] {
case '0':
out.WriteByte('~')
case '1':
out.WriteByte('/')
default:
return "", fmt.Errorf("invalid escape: ~%c must be ~0 or ~1", raw[i+1])
}
i++
}
return out.String(), nil
}

View File

@@ -98,6 +98,41 @@ func TestReadJSONPointer_RFC6901_Escaping(t *testing.T) {
}
}
func TestReadJSONPointer_InvalidEscape(t *testing.T) {
data := map[string]interface{}{
"a~2b": "literal",
"a~": "literal",
}
tests := []struct {
name string
pointer string
want string
}{
{
name: "unsupported escape code",
pointer: "/a~2b",
want: `json pointer "/a~2b": segment "a~2b": invalid escape: ~2 must be ~0 or ~1`,
},
{
name: "dangling tilde",
pointer: "/a~",
want: `json pointer "/a~": segment "a~": invalid escape: ~ must be followed by 0 or 1`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ReadJSONPointer(data, tt.pointer)
if err == nil {
t.Fatal("expected error for invalid escape, got nil")
}
if err.Error() != tt.want {
t.Errorf("error = %q, want %q", err.Error(), tt.want)
}
})
}
}
func TestReadJSONPointer_InvalidFormat(t *testing.T) {
data := map[string]interface{}{"key": "val"}
_, err := ReadJSONPointer(data, "no-leading-slash")

View File

@@ -169,7 +169,7 @@ type ProviderConfig struct {
const (
DefaultFileTimeoutMs = 5000
DefaultFileMaxBytes = 1024 * 1024 // 1 MiB
DefaultExecTimeoutMs = 5000
DefaultExecTimeoutMs = 10000
DefaultExecMaxOutputBytes = 1024 * 1024 // 1 MiB
)

View File

@@ -160,10 +160,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
mode := f.ResolveStrictMode(ctx)
if mode.IsActive() && !mode.AllowsIdentity(as) {
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, only %s identity is allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode, mode.ForcedIdentity())
return output.ErrWithHint(output.ExitValidation, "strict_mode",
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}
return nil
}

View File

@@ -14,8 +14,8 @@ import (
// AddAPIIdentityFlag registers the standard --as flag shape used by api/service commands.
func AddAPIIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string) {
addIdentityFlag(ctx, cmd, f, target, identityFlagConfig{
defaultValue: "auto",
usage: "identity type: user | bot | auto (default)",
defaultValue: "",
usage: "identity type: user | bot",
completionValues: []string{"user", "bot"},
})
}
@@ -26,7 +26,7 @@ func AddShortcutIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory
authTypes = []string{"user"}
}
addIdentityFlag(ctx, cmd, f, nil, identityFlagConfig{
defaultValue: authTypes[0],
defaultValue: "",
usage: "identity type: " + strings.Join(authTypes, " | "),
completionValues: authTypes,
})

View File

@@ -24,8 +24,8 @@ func TestAddAPIIdentityFlag_NonStrictMode(t *testing.T) {
if flag.Hidden {
t.Fatal("expected --as flag to be visible outside strict mode")
}
if got := flag.DefValue; got != "auto" {
t.Fatalf("default value = %q, want %q", got, "auto")
if got := flag.DefValue; got != "" {
t.Fatalf("default value = %q, want empty string", got)
}
}
@@ -49,7 +49,7 @@ func TestAddAPIIdentityFlag_StrictModeHidesFlagAndLocksDefault(t *testing.T) {
}
}
func TestAddShortcutIdentityFlag_UsesAuthTypes(t *testing.T) {
func TestAddShortcutIdentityFlag_NoDefault(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
@@ -62,7 +62,7 @@ func TestAddShortcutIdentityFlag_UsesAuthTypes(t *testing.T) {
if flag.Hidden {
t.Fatal("expected --as flag to be visible outside strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
if got := flag.DefValue; got != "" {
t.Fatalf("default value = %q, want empty string", got)
}
}

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

@@ -225,7 +225,7 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
raw, err := LoadMultiAppConfig()
if err != nil || raw == nil || len(raw.Apps) == 0 {
return nil, &ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
return nil, NotConfiguredError()
}
return ResolveConfigFromMulti(raw, kc, profileOverride)
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"fmt"
"os"
)
// LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet
// configured vs. couldn't read" disambiguation that every config-required
// command should use:
//
// - file missing → workspace-aware NotConfiguredError (init / bind hint)
// - parse error / permission error → real load failure with the original
// cause preserved, so the user can actually fix the broken file
//
// Without this, every call site that did `if err != nil { return
// NotConfiguredError() }` silently coerced corrupt-config into "run init",
// which sent users in circles when their config.json was just malformed.
func LoadOrNotConfigured() (*MultiAppConfig, error) {
multi, err := LoadMultiAppConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, NotConfiguredError()
}
// Surface the real cause (parse error, permission denied, etc.)
// so the user can fix the broken file. Wrapping as ConfigError
// keeps it on the standard structured-envelope path at the root
// command's error sink.
return nil, &ConfigError{
Code: 2,
Type: "config",
Message: fmt.Sprintf("failed to load config: %v", err),
}
}
if multi == nil || len(multi.Apps) == 0 {
return nil, NotConfiguredError()
}
return multi, nil
}
const (
// localInitHint is the canonical "you're in a regular terminal, run
// init" guidance — shared by NotConfiguredError and NoActiveProfileError
// so the same session can't show two different recommended commands.
localInitHint = "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."
// agentBindHint is the canonical "you're in an Agent workspace, see
// the binding workflow" guidance. Always points at --help (never a
// ready-to-run bind command) so the AI reads the confirmation
// discipline (identity preset, user opt-in) before acting.
agentBindHint = "read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`"
)
// NotConfiguredError returns the canonical "not configured" error, with a
// hint that depends on the active workspace:
//
// - WorkspaceLocal → suggest `config init --new` (creates a new app).
// - WorkspaceOpenClaw / WorkspaceHermes → point at `config bind --help`
// rather than a ready-to-run command, because binding is policy-laden:
// the user must pick an identity preset (bot-only vs user-default),
// and re-binding may overwrite an existing one. The help text walks
// the AI through the confirmation flow.
//
// All "config not loaded yet" call sites should use this helper rather than
// hand-rolling a hint, so AI agents always get a workspace-correct next step.
func NotConfiguredError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
return &ConfigError{
Code: 2,
Type: "config",
Message: "not configured",
Hint: localInitHint,
}
}
return &ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
Hint: agentBindHint,
}
}
// reconfigureHint returns the workspace-aware "fix it from scratch" hint
// used by error paths that aren't full ConfigErrors (e.g. plain fmt.Errorf
// strings from keychain / secret validation). Local → `config init`;
// Agent → `config bind --help` so the AI reads the binding workflow and
// confirms identity preset with the user before running the actual command.
func reconfigureHint() string {
if CurrentWorkspace().IsLocal() {
return "please run `lark-cli config init` to reconfigure"
}
return agentBindHint
}
// NoActiveProfileError mirrors NotConfiguredError for the related
// "config exists but the requested profile cannot be resolved" case. In agent
// workspaces a missing profile typically means the binding was wiped while
// the workspace marker remained — re-binding is the correct fix, not init.
func NoActiveProfileError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
return &ConfigError{
Code: 2,
Type: "config",
Message: "no active profile",
Hint: localInitHint,
}
}
return &ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
Hint: agentBindHint,
}
}

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"os"
"strings"
"testing"
)
// saveAndRestoreWorkspace ensures package-level currentWorkspace is reset
// between subtests so cross-test pollution can't make assertions pass by
// accident.
func saveAndRestoreWorkspace(t *testing.T) {
t.Helper()
prev := CurrentWorkspace()
t.Cleanup(func() { SetCurrentWorkspace(prev) })
}
func TestNotConfiguredError_Local(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceLocal)
err := NotConfiguredError()
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Errorf("unexpected detail: %+v", cfgErr)
}
if !strings.Contains(cfgErr.Hint, "config init --new") {
t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint)
}
if strings.Contains(cfgErr.Hint, "config bind") {
t.Errorf("local hint must not mention config bind; got %q", cfgErr.Hint)
}
}
func TestNotConfiguredError_OpenClaw(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceOpenClaw)
err := NotConfiguredError()
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
// Hint must point at --help (read first, confirm with user, then bind),
// NOT a directly-executable bind command — binding is policy-laden
// (identity preset, may overwrite existing binding).
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
}
if strings.Contains(cfgErr.Hint, "config init") {
t.Errorf("agent hint must NOT mention config init (would cause AI to create a new app); got %q", cfgErr.Hint)
}
}
func TestNotConfiguredError_Hermes(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceHermes)
err := NotConfiguredError()
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint)
}
}
func TestNoActiveProfileError_Local(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceLocal)
err := NoActiveProfileError()
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Message != "no active profile" {
t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile")
}
}
func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceOpenClaw)
err := NoActiveProfileError()
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
}
}
func TestReconfigureHint_Local(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceLocal)
got := reconfigureHint()
if !strings.Contains(got, "config init") {
t.Errorf("local reconfigure hint must mention config init; got %q", got)
}
}
func TestReconfigureHint_Agent(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceHermes)
got := reconfigureHint()
if !strings.Contains(got, "config bind --help") {
t.Errorf("agent reconfigure hint must point to `config bind --help`; got %q", got)
}
}
func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceLocal)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
_, err := LoadOrNotConfigured()
if err == nil {
t.Fatal("expected error")
}
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Message != "not configured" {
t.Errorf("message = %q, want \"not configured\"", cfgErr.Message)
}
if !strings.Contains(cfgErr.Hint, "config init --new") {
t.Errorf("missing-file in local must hint `config init --new`; got %q", cfgErr.Hint)
}
}
// TestLoadOrNotConfigured_CorruptFile_PreservesCause is the regression guard
// for the previous "every load error → not configured" coercion: a malformed
// config.json must surface its real failure cause so the user can fix it,
// not get sent in circles by an init/bind hint that wouldn't help here.
func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Write garbage that will fail JSON parsing.
if err := os.WriteFile(dir+"/config.json", []byte("{not valid json"), 0600); err != nil {
t.Fatal(err)
}
_, err := LoadOrNotConfigured()
if err == nil {
t.Fatal("expected error for corrupt config")
}
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if !strings.Contains(cfgErr.Message, "failed to load config") {
t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message)
}
// And it must NOT pretend the user just hasn't initialised yet.
if cfgErr.Message == "not configured" {
t.Errorf("corrupt-file must not be coerced to 'not configured'")
}
if strings.Contains(cfgErr.Hint, "config init") || strings.Contains(cfgErr.Hint, "config bind") {
t.Errorf("corrupt-file hint must not redirect to init/bind; got %q", cfgErr.Hint)
}
}

View File

@@ -63,9 +63,8 @@ func ValidateSecretKeyMatch(appId string, secret SecretInput) error {
expected := secretAccountKey(appId)
if secret.Ref.ID != expected {
return fmt.Errorf(
"appSecret keychain key %q does not match appId %q (expected %q); "+
"please run `lark-cli config init` to reconfigure",
secret.Ref.ID, appId, expected,
"appSecret keychain key %q does not match appId %q (expected %q); %s",
secret.Ref.ID, appId, expected, reconfigureHint(),
)
}
return nil

View File

@@ -203,7 +203,7 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
return acct, nil
}
return nil, fmt.Errorf("no credential provider returned an account; run 'lark-cli config' to set up")
return nil, core.NotConfiguredError()
}
// enrichUserInfo resolves user identity when extension provides a UAT.

View File

@@ -36,7 +36,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
// Load config once — used for both credentials and strict mode.
multi, err := core.LoadMultiAppConfig()
if err != nil {
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
return nil, core.NotConfiguredError()
}
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)

View File

@@ -10,7 +10,7 @@ const (
ExitOK = 0 // 成功
ExitAPI = 1 // API / 通用错误(含 permission、not_found、conflict、rate_limit
ExitValidation = 2 // 参数校验失败
ExitAuth = 3 // 认证失败token 无效 / 过期)
ExitAuth = 3 // 认证失败token 无效 / 过期),或登录成功但请求 scopes 未全部授予
ExitNetwork = 4 // 网络错误连接超时、DNS 解析失败等)
ExitInternal = 5 // 内部错误(不应发生)
ExitContentSafety = 6 // content safety violation (block mode)

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

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
// Init runs the synchronous skills version check. Stores a StaleNotice
// when the local stamp does not match currentVersion. Safe to call
// from cmd/root.go before rootCmd.Execute(); zero network, zero
// subprocess — only a local stamp file read.
//
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
//
// Failure modes (all → no notice, no nag):
// - shouldSkip rule met
// - ReadStamp returns an I/O error other than ENOENT
// - Stamp matches currentVersion (in-sync)
func Init(currentVersion string) {
// Clear any stale notice from a prior call so early returns below
// (skip rules / read errors / in-sync) leave pending == nil instead
// of preserving a stale value from a previous Init invocation.
SetPending(nil)
if shouldSkip(currentVersion) {
return
}
stamp, err := ReadStamp()
if err != nil {
// Fail closed — don't nag for a transient FS problem.
return
}
if stamp == currentVersion {
return
}
SetPending(&StaleNotice{
Current: stamp, // "" when never synced
Target: currentVersion,
})
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"os"
"path/filepath"
"testing"
)
func resetPending(t *testing.T) {
t.Helper()
SetPending(nil)
t.Cleanup(func() { SetPending(nil) })
}
func TestInit_InSync_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
Init("1.0.21")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (in-sync)", got)
}
}
func TestInit_ColdStart_NoticeWithEmptyCurrent(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
Init("1.0.21")
got := GetPending()
if got == nil {
t.Fatal("GetPending() = nil, want non-nil for cold start")
}
if got.Current != "" || got.Target != "1.0.21" {
t.Errorf("notice = %+v, want {Current:\"\", Target:\"1.0.21\"}", got)
}
}
func TestInit_Drift_NoticeWithStampVersion(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
Init("1.0.21")
got := GetPending()
if got == nil {
t.Fatal("GetPending() = nil, want non-nil for drift")
}
if got.Current != "1.0.20" || got.Target != "1.0.21" {
t.Errorf("notice = %+v, want {Current:\"1.0.20\", Target:\"1.0.21\"}", got)
}
}
func TestInit_Skipped_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
// Even with an empty config dir (no stamp), DEV version should skip
// the check entirely and never emit a notice.
Init("DEV")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (skip rules met)", got)
}
}
func TestInit_ReadStampError_FailsClosed(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Make the stamp path a directory so vfs.ReadFile returns a
// non-ENOENT I/O error.
if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil {
t.Fatal(err)
}
Init("1.0.21")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (fail closed on I/O error)", got)
}
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skillscheck verifies that the locally installed lark-cli
// skills are in sync with the running binary version, by comparing
// the current binary version against a stamp file written when skills
// are last synced (by `lark-cli update`). On mismatch it stores a
// notice for injection into JSON envelopes via output.PendingNotice.
package skillscheck
import (
"fmt"
"sync/atomic"
)
// StaleNotice signals that the locally synced skills version does not
// match the running binary. Current is the last successfully synced
// version (or "" when never synced); Target is the running binary
// version. Mirrors internal/update.UpdateInfo's pending-notice pattern.
type StaleNotice struct {
Current string `json:"current"`
Target string `json:"target"`
}
// Message returns a single-line, AI-agent-parseable description of the
// gap plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
// in style ("..., run: lark-cli update" suffix).
func (s *StaleNotice) Message() string {
if s.Current == "" {
return "lark-cli skills not installed, run: lark-cli update"
}
return fmt.Sprintf(
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",
s.Current, s.Target,
)
}
// pending stores the latest stale notice for the current process.
var pending atomic.Pointer[StaleNotice]
// SetPending stores the stale notice for consumption by output decorators.
// Pass nil to clear.
func SetPending(n *StaleNotice) { pending.Store(n) }
// GetPending returns the pending stale notice, or nil.
func GetPending() *StaleNotice { return pending.Load() }

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"sync"
"testing"
)
func TestStaleNotice_Message(t *testing.T) {
tests := []struct {
name string
n StaleNotice
want string
}{
{
"cold_start",
StaleNotice{Current: "", Target: "1.0.21"},
"lark-cli skills not installed, run: lark-cli update",
},
{
"drift",
StaleNotice{Current: "1.0.20", Target: "1.0.21"},
"lark-cli skills 1.0.20 out of sync with binary 1.0.21, run: lark-cli update",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.n.Message(); got != tt.want {
t.Errorf("Message() = %q, want %q", got, tt.want)
}
})
}
}
func TestSetGetPending(t *testing.T) {
SetPending(nil)
t.Cleanup(func() { SetPending(nil) })
if got := GetPending(); got != nil {
t.Fatalf("initial GetPending() = %+v, want nil", got)
}
want := &StaleNotice{Current: "1.0.20", Target: "1.0.21"}
SetPending(want)
got := GetPending()
if got == nil || got.Current != "1.0.20" || got.Target != "1.0.21" {
t.Errorf("GetPending() = %+v, want %+v", got, want)
}
}
func TestSetGetPending_Concurrent(t *testing.T) {
SetPending(nil)
t.Cleanup(func() { SetPending(nil) })
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func() {
defer wg.Done()
SetPending(&StaleNotice{Current: "a", Target: "b"})
}()
go func() {
defer wg.Done()
_ = GetPending()
}()
}
wg.Wait()
// Just verifying no race; -race flag enforces.
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"os"
"github.com/larksuite/cli/internal/update"
)
// shouldSkip returns true when the skills check should be silently
// suppressed. Mirrors internal/update.shouldSkip semantics but uses
// a dedicated opt-out env var so users can disable the skills nag
// without also disabling the binary update nag.
func shouldSkip(version string) bool {
if os.Getenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER") != "" {
return true
}
if update.IsCIEnv() {
return true
}
if version == "DEV" || version == "dev" || version == "" {
return true
}
return !update.IsRelease(version)
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"os"
"testing"
)
// clearSkillsSkipEnv unsets the env vars shouldSkip checks so the
// host environment cannot pollute test results.
func clearSkillsSkipEnv(t *testing.T) {
t.Helper()
for _, key := range []string{"LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "CI", "BUILD_NUMBER", "RUN_ID"} {
t.Setenv(key, "")
os.Unsetenv(key)
}
}
func TestShouldSkip(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T)
version string
want bool
}{
{"release_no_skip", clearSkillsSkipEnv, "1.0.21", false},
{"dev_uppercase", clearSkillsSkipEnv, "DEV", true},
{"dev_lowercase", clearSkillsSkipEnv, "dev", true},
{"empty_version", clearSkillsSkipEnv, "", true},
{"git_describe", clearSkillsSkipEnv, "1.0.0-12-g9b933f1-dirty", true},
{"opt_out", func(t *testing.T) {
clearSkillsSkipEnv(t)
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
}, "1.0.21", true},
{"ci_env", func(t *testing.T) {
clearSkillsSkipEnv(t)
t.Setenv("CI", "true")
}, "1.0.21", true},
{"build_number_env", func(t *testing.T) {
clearSkillsSkipEnv(t)
t.Setenv("BUILD_NUMBER", "42")
}, "1.0.21", true},
{"run_id_env", func(t *testing.T) {
clearSkillsSkipEnv(t)
t.Setenv("RUN_ID", "abc")
}, "1.0.21", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setup(t)
if got := shouldSkip(tt.version); got != tt.want {
t.Errorf("shouldSkip(%q) = %v, want %v", tt.version, got, tt.want)
}
})
}
}
// Independent opt-out: LARKSUITE_CLI_NO_SKILLS_NOTIFIER must NOT be
// affected by LARKSUITE_CLI_NO_UPDATE_NOTIFIER (different env vars).
func TestShouldSkip_OptOutIsIndependent(t *testing.T) {
clearSkillsSkipEnv(t)
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1") // update opt-out, not us
if shouldSkip("1.0.21") {
t.Error("shouldSkip(release) = true with only LARKSUITE_CLI_NO_UPDATE_NOTIFIER set, want false")
}
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"errors"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const stampFile = "skills.stamp"
// stampPath returns ~/.lark-cli/skills.stamp.
// Uses the BASE config dir (not workspace-aware) because skills install
// globally via `npx -g`; per-workspace tracking would produce false
// drift signals when switching workspaces.
func stampPath() string {
return filepath.Join(core.GetBaseConfigDir(), stampFile)
}
// ReadStamp returns the version recorded in the stamp file. Returns
// ("", nil) when the file does not exist (interpreted as "never synced").
// Other I/O errors are returned as-is so callers can fail closed.
func ReadStamp() (string, error) {
data, err := vfs.ReadFile(stampPath())
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", nil
}
return "", err
}
return strings.TrimSpace(string(data)), nil
}
// WriteStamp records `version` as the last successfully synced skills
// version. Atomic via tmp + rename (validate.AtomicWrite). Creates
// the base config directory if it does not exist.
func WriteStamp(version string) error {
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
return err
}
return validate.AtomicWrite(stampPath(), []byte(version), 0o644)
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"os"
"path/filepath"
"testing"
)
func TestReadStamp_Missing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got, err := ReadStamp()
if err != nil {
t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err)
}
if got != "" {
t.Errorf("ReadStamp() = %q, want \"\" for missing file", got)
}
}
func TestReadStamp_Normal(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadStamp()
if err != nil || got != "1.0.21" {
t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err)
}
}
func TestReadStamp_TrailingNewlineTolerated(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil {
t.Fatal(err)
}
got, _ := ReadStamp()
if got != "1.0.21" {
t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got)
}
}
func TestReadStamp_EmptyFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadStamp()
if err != nil || got != "" {
t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err)
}
}
func TestWriteStamp_CreatesDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatalf("WriteStamp() = %v, want nil", err)
}
got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
if string(got) != "1.0.21" {
t.Errorf("file content = %q, want \"1.0.21\"", string(got))
}
}
func TestWriteStamp_OverwritesExisting(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
got, _ := ReadStamp()
if got != "1.0.21" {
t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got)
}
}
func TestWriteStamp_NoTrailingNewline(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
if string(raw) != "1.0.21" {
t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw))
}
}
// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error
// when the base config dir cannot be created (parent path is a regular file).
func TestWriteStamp_MkdirAllFailure(t *testing.T) {
tmp := t.TempDir()
blocker := filepath.Join(tmp, "blocker")
// Create a regular file where MkdirAll wants to create a directory.
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
}
// Point the config dir at a path UNDER the regular file — MkdirAll must fail.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child"))
if err := WriteStamp("1.0.21"); err == nil {
t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure")
}
}

View File

@@ -37,9 +37,12 @@ type UpdateInfo struct {
Latest string `json:"latest"`
}
// Message returns a concise update notification.
// Message returns a concise update notification including the canonical
// fix command. Aligned with skillscheck.StaleNotice.Message style so
// AI agents can parse a unified "run: lark-cli update" hint across
// both notice types.
func (u *UpdateInfo) Message() string {
return fmt.Sprintf("lark-cli %s available, current %s", u.Latest, u.Current)
return fmt.Sprintf("lark-cli %s available, current %s, run: lark-cli update", u.Latest, u.Current)
}
// pending stores the latest update info for the current process.
@@ -111,10 +114,8 @@ func shouldSkip(version string) bool {
return true
}
// Suppress in CI environments.
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
if os.Getenv(key) != "" {
return true
}
if IsCIEnv() {
return true
}
// No version info at all — can't compare.
if version == "DEV" || version == "dev" || version == "" {
@@ -141,6 +142,24 @@ func isRelease(version string) bool {
return !gitDescribePattern.MatchString(v)
}
// IsRelease reports whether version looks like a clean published release
// (semver "1.0.0", or npm prerelease "1.0.0-beta.1") and not a git-describe
// dev build like "1.0.0-12-g9b933f1-dirty". Exported so internal/skillscheck
// can apply the same release-only gating without duplicating the regex.
func IsRelease(version string) bool { return isRelease(version) }
// IsCIEnv returns true when any of the standard CI environment variables
// is set. Exported for internal/skillscheck so its skip rules track the
// same CI-suppression behavior as the update notifier.
func IsCIEnv() bool {
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
if os.Getenv(key) != "" {
return true
}
}
return false
}
// --- state file I/O ---
func statePath() string {

View File

@@ -10,7 +10,6 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -143,28 +142,27 @@ func TestShouldSkip(t *testing.T) {
func TestIsRelease(t *testing.T) {
tests := []struct {
version string
want bool
name string
ver string
want bool
}{
{"1.0.0", true},
{"v1.0.0", true},
{"0.1.0", true},
{"1.0.0-beta.1", true},
{"1.0.0-rc.1", true},
{"2.0.0-alpha.0", true},
{"v1.0.0-12-g9b933f1", false}, // git describe
{"v1.0.0-12-g9b933f1-dirty", false}, // git describe dirty
{"v2.1.0-3-gabcdef0", false}, // git describe short
{"9b933f1", false}, // bare commit hash
{"DEV", false}, // dev marker
{"", false}, // empty
{"1.0", false}, // incomplete semver
{"clean_semver", "1.0.0", true},
{"v_prefix", "v1.0.0", true},
{"prerelease", "1.0.0-beta.1", true},
{"rc", "1.0.0-rc.1", true},
{"alpha_prerelease", "2.0.0-alpha.0", true},
{"git_describe_dirty", "1.0.0-12-g9b933f1-dirty", false},
{"git_describe_clean", "1.0.0-12-g9b933f1", false},
{"bare_commit_hash", "9b933f1", false},
{"dev_marker", "DEV", false},
{"incomplete_semver", "1.0", false},
{"empty", "", false},
{"invalid", "not-a-version", false},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
got := isRelease(tt.version)
if got != tt.want {
t.Errorf("isRelease(%q) = %v, want %v", tt.version, got, tt.want)
t.Run(tt.name, func(t *testing.T) {
if got := IsRelease(tt.ver); got != tt.want {
t.Errorf("IsRelease(%q) = %v, want %v", tt.ver, got, tt.want)
}
})
}
@@ -172,13 +170,10 @@ func TestIsRelease(t *testing.T) {
func TestUpdateInfoMethods(t *testing.T) {
info := &UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
msg := info.Message()
if !strings.Contains(msg, "2.0.0") {
t.Errorf("Message() missing latest version: %s", msg)
}
if !strings.Contains(msg, "1.0.0") {
t.Errorf("Message() missing current version: %s", msg)
got := info.Message()
want := "lark-cli 2.0.0 available, current 1.0.0, run: lark-cli update"
if got != want {
t.Errorf("Message() = %q, want %q", got, want)
}
}
@@ -264,3 +259,19 @@ func TestPendingAtomicAccess(t *testing.T) {
// Clean up for other tests
SetPending(nil)
}
func TestIsCIEnv(t *testing.T) {
clearSkipEnv(t)
if IsCIEnv() {
t.Fatal("IsCIEnv() = true after clearSkipEnv, want false")
}
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
t.Run(key, func(t *testing.T) {
clearSkipEnv(t)
t.Setenv(key, "1")
if !IsCIEnv() {
t.Errorf("IsCIEnv() = false with %s=1, want true", key)
}
})
}
}

View File

@@ -5,6 +5,9 @@ package util
// TruncateStr truncates s to at most n runes, safe for multi-byte (e.g. CJK) characters.
func TruncateStr(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(s)
if len(r) <= n {
return s
@@ -14,6 +17,9 @@ func TruncateStr(s string, n int) string {
// TruncateStrWithEllipsis truncates s to at most n runes (including "..." suffix).
func TruncateStrWithEllipsis(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(s)
if len(r) <= n {
return s

View File

@@ -17,6 +17,7 @@ func TestTruncateStr(t *testing.T) {
{"truncate", "hello world", 5, "hello"},
{"empty", "", 5, ""},
{"zero limit", "hello", 0, ""},
{"negative limit", "hello", -1, ""},
{"CJK characters", "你好世界测试", 4, "你好世界"},
}
for _, tt := range tests {
@@ -41,6 +42,8 @@ func TestTruncateStrWithEllipsis(t *testing.T) {
{"limit less than 3", "hello", 2, "he"},
{"limit equals 3", "hello world", 3, "..."},
{"empty", "", 5, ""},
{"zero limit", "hello", 0, ""},
{"negative limit", "hello", -1, ""},
{"CJK with ellipsis", "你好世界测试", 5, "你好..."},
}
for _, tt := range tests {

View File

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

View File

@@ -112,11 +112,43 @@ func TestDryRunRecordOps(t *testing.T) {
nil,
map[string]int{"max-version": 11, "page-size": 30},
)
assertDryRunContains(t, dryRunRecordGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
assertDryRunContains(t, dryRunRecordUpsert(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
assertDryRunContains(t, dryRunRecordDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
assertDryRunContains(t, dryRunRecordHistoryList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/record_history", "max_version=11", "page_size=30", "record_id=rec_1", "table_id=tbl_1")
getSingleRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"record-id": {"rec_1"}},
nil,
nil,
)
assertDryRunContains(t, dryRunRecordGet(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`)
assertDryRunContains(t, dryRunRecordDelete(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_1"]`)
getSingleFieldsRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"record-id": {"rec_1"}, "field-id": {"Name", "Age"}},
nil,
nil,
)
assertDryRunContains(t, dryRunRecordGet(ctx, getSingleFieldsRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`, `"select_fields":["Name","Age"]`)
getBatchRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"record-id": {"rec_2", "rec_1"}, "field-id": {"Name", "Age"}},
nil,
nil,
)
assertDryRunContains(t, dryRunRecordGet(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_2","rec_1"]`, `"select_fields":["Name","Age"]`)
assertDryRunContains(t, dryRunRecordDelete(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_2","rec_1"]`)
getJSONRT := newBaseTestRuntime(
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"record_id_list":["rec_3"],"select_fields":["Status"]}`},
nil,
nil,
)
assertDryRunContains(t, dryRunRecordGet(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_3"]`, `"select_fields":["Status"]`)
assertDryRunContains(t, dryRunRecordDelete(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_3"]`)
uploadAttachmentRT := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",

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)
@@ -986,42 +1054,322 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("get", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
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}},
}},
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
})
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_ids"`) || !strings.Contains(got, `"Name"`) || strings.Contains(got, `"raw"`) {
got := stdout.String()
for _, want := range []string{
"`_record_id` is metadata for record operations, not a table field.",
"- `_record_id`: rec_1",
"- `Name`: Alice",
"- `Age`: 18",
"Meta: count=1",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_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, `"fields"`) || !strings.Contains(got, `"Alice"`) || !strings.Contains(got, `"Age"`) || strings.Contains(got, `"record":`) || strings.Contains(got, `"raw"`) {
t.Fatalf("stdout=%s", got)
}
if got := stdout.String(); !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("get passthrough fallback", func(t *testing.T) {
t.Run("get with selected fields", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_2",
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"unexpected": "shape", "record_id": "rec_2"},
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
})
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2"}, factory, stdout); err != nil {
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_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, `"unexpected": "shape"`) || strings.Contains(got, `"raw"`) || strings.Contains(got, `"record":`) {
if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Name"`) || !strings.Contains(got, `"Age"`) || !strings.Contains(got, `"Alice"`) || strings.Contains(got, `"record":`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get batch with repeated record-id flags", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_2", "rec_1"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"| _record_id | Name |",
"| rec_2 | Bob |",
"| rec_1 | Alice |",
"Meta: count=2",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get batch json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_2", "rec_1"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) || !strings.Contains(got, `"Bob"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("get batch with json selector", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_3"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{"Carol"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"],"select_fields":["Name"]}`, "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_3"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get single returns batch_get error when batch_get is unavailable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Status: 404,
Body: map[string]interface{}{"code": 404, "msg": "not found"},
}
reg.Register(batchStub)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout)
if err == nil {
t.Fatalf("expected batch_get error")
}
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
}
if stdout.Len() != 0 {
t.Fatalf("stdout=%s", stdout.String())
}
})
t.Run("get single missing record renders not found markdown", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_missing"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{nil}},
"has_more": false,
"record_not_found": []interface{}{"rec_missing"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_missing"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"Record not found.",
"- `_record_id`: rec_missing",
"Meta: count=1; has_more=false; record_not_found=1",
"Missing records: rec_missing",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "- `Name`:") {
t.Fatalf("missing record output should not render business fields:\n%s", got)
}
})
t.Run("get batch returns batch_get error when batch_get is unavailable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Status: 404,
Body: map[string]interface{}{"code": 404, "msg": "not found"},
}
reg.Register(batchStub)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout)
if err == nil {
t.Fatalf("expected batch_get error")
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
t.Fatalf("request body=%s", body)
}
if stdout.Len() != 0 {
t.Fatalf("stdout=%s", stdout.String())
}
})
t.Run("get batch with json record ids and field flags", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_4"},
"fields": []interface{}{"Status"},
"data": []interface{}{[]interface{}{"Done"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_4"]}`, "--field-id", "Status", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"Done"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_4"]`) || !strings.Contains(body, `"select_fields":["Status"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get rejects duplicate record ids", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects duplicate field ids", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "duplicate field id") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects mixed record-id and json", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects mixed field-id and json select_fields", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_2"],"select_fields":["Name"]}`, "--field-id", "Age"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "select_fields") || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects empty selection", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
t.Fatalf("err=%v", err)
}
})
t.Run("create", func(t *testing.T) {
@@ -1121,17 +1469,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("delete", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"record_id": "rec_1"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || strings.Contains(got, `"deleted": true`) {
t.Fatalf("stdout=%s", got)
}
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
}
})
t.Run("delete returns batch_delete error when unavailable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Status: 404,
Body: map[string]interface{}{"code": 404, "msg": "not found"},
}
reg.Register(batchStub)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout)
if err == nil {
t.Fatalf("expected batch_delete error")
}
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
}
if stdout.Len() != 0 {
t.Fatalf("stdout=%s", stdout.String())
}
})
t.Run("delete batch with repeated record-id flags", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_2", "rec_1"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("delete batch with json selector", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_3"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"]}`, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_3"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_3"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("delete requires yes for batch", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("err=%v", err)
}
})
t.Run("delete rejects duplicate record ids", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1", "--yes"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
t.Fatalf("err=%v", err)
}
})
t.Run("delete rejects mixed record-id and json", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`, "--yes"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("upload attachment", func(t *testing.T) {
@@ -1674,7 +2126,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,140 @@ 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 (repeatable)",
"field ID or name to project; repeat to keep only needed columns",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
"Default output is markdown",
"projection boundary",
"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 TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
wantHelp := []string{
"complete field definition JSON object; update uses full PUT semantics, not a patch",
}
for _, want := range wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
"full field-definition PUT semantics",
"Read the current field first with +field-get",
"Type conversion is allowlist-based",
"web UI",
"Formula and lookup updates require reading the corresponding guide first.",
"lark-base skill's field-update guide",
}
for _, want := range 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") {
@@ -259,8 +393,8 @@ func TestBaseRecordValidate(t *testing.T) {
if BaseRecordSearch.Validate == nil {
t.Fatalf("record search validate should reject invalid JSON before dry-run")
}
if BaseRecordGet.Validate != nil {
t.Fatalf("record get validate should be nil")
if BaseRecordGet.Validate == nil {
t.Fatalf("record get validate should reject invalid record selection before dry-run")
}
if BaseRecordUpsert.Validate == nil {
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")

View File

@@ -20,12 +20,16 @@ var BaseFieldUpdate = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
fieldRefFlag(true),
{Name: "json", Desc: "field property JSON object", Required: true},
{Name: "json", Desc: "complete field definition JSON object; update uses full PUT semantics, not a patch", Required: true},
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
},
Tips: []string{
`Example: --json '{"name":"Status","type":"text"}'`,
"Agent hint: use the lark-base skill's field-update guide for usage and limits.",
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
"Formula and lookup updates require reading the corresponding guide first.",
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFieldUpdate(runtime)

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

@@ -195,6 +195,62 @@ func TestRecordAndChunkHelpers(t *testing.T) {
}
}
func TestRecordSelectionHelpers(t *testing.T) {
recordIDs, err := normalizeRecordIDs([]string{" rec_1 ", "rec_2"})
if err != nil || !reflect.DeepEqual(recordIDs, []string{"rec_1", "rec_2"}) {
t.Fatalf("recordIDs=%v err=%v", recordIDs, err)
}
if _, err := normalizeRecordIDs([]interface{}{}); err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
t.Fatalf("err=%v", err)
}
if _, err := normalizeRecordIDs([]interface{}{"rec_1", "rec_1"}); err == nil || !strings.Contains(err.Error(), "duplicate record id") {
t.Fatalf("err=%v", err)
}
if _, err := normalizeRecordIDs([]interface{}{" "}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}
if _, err := normalizeRecordIDs([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") {
t.Fatalf("err=%v", err)
}
tooManyRecords := make([]string, maxRecordSelectionCount+1)
if _, err := normalizeRecordIDs(tooManyRecords); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") {
t.Fatalf("err=%v", err)
}
fields, err := normalizeRecordGetSelectFields([]interface{}{" Name ", "fld_status"})
if err != nil || !reflect.DeepEqual(fields, []string{"Name", "fld_status"}) {
t.Fatalf("fields=%v err=%v", fields, err)
}
if fields, err := normalizeRecordGetSelectFields(nil); err != nil || fields != nil {
t.Fatalf("fields=%v err=%v", fields, err)
}
if _, err := normalizeRecordGetSelectFields([]interface{}{"Name", "Name"}); err == nil || !strings.Contains(err.Error(), "duplicate field id") {
t.Fatalf("err=%v", err)
}
if _, err := normalizeRecordGetSelectFields([]interface{}{""}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}
if _, err := normalizeRecordGetSelectFields([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") {
t.Fatalf("err=%v", err)
}
tooManyFields := make([]string, maxBatchGetSelectFieldCount+1)
if _, err := normalizeRecordGetSelectFields(tooManyFields); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") {
t.Fatalf("err=%v", err)
}
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
t.Fatalf("fields=%v err=%v", fields, err)
}
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}
}
func TestResolveHelpers(t *testing.T) {
fields := []map[string]interface{}{{"id": "fld_1", "name": "Name", "type": "text"}, {"field_id": "fld_2", "field_name": "Age", "type": "number", "multiple": true}}
tables := []map[string]interface{}{{"id": "tbl_1", "name": "Orders"}}

View File

@@ -12,12 +12,20 @@ import (
var BaseRecordDelete = common.Shortcut{
Service: "base",
Command: "+record-delete",
Description: "Delete a record by ID",
Description: "Delete one or more records by ID",
Risk: "high-risk-write",
Scopes: []string{"base:record:delete"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), recordRefFlag(true)},
DryRun: dryRunRecordDelete,
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordSelection(runtime)
},
DryRun: dryRunRecordDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordDelete(runtime)
},

View File

@@ -7,21 +7,42 @@ import (
"context"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var BaseRecordGet = common.Shortcut{
Service: "base",
Command: "+record-get",
Description: "Get a record by ID",
Description: "Get one or more records by ID",
Risk: "read",
Scopes: []string{"base:record:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
recordRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
recordReadFormatFlag(),
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
return validateRecordSelection(runtime)
},
Tips: []string{
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"Example with projection: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use --field-id as a projection boundary to avoid loading large cell values into context when they are not needed.",
"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,337 @@
// 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 {
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordMarkdown)
}
func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) 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 := renderer(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 outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
}
func renderRecordGetMarkdown(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")
}
if len(recordIDs) == 1 && len(rows) == 1 {
rowItems, _ := rows[0].([]interface{})
if recordMarkedNotFound(data["record_not_found"], recordIDs[0]) {
return renderMissingSingleRecordMarkdown(recordIDs[0], data), nil
}
return renderSingleRecordMarkdown(recordIDs[0], fields, rowItems, data), nil
}
return renderRecordMarkdown(data)
}
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')
}
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
b.WriteString("Missing records: ")
b.WriteString(missing)
b.WriteByte('\n')
}
return b.String(), nil
}
func renderSingleRecordMarkdown(recordID string, fields []string, rowItems []interface{}, data map[string]interface{}) string {
var b strings.Builder
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
b.WriteString("- `_record_id`: ")
b.WriteString(markdownInlineValue(recordID))
b.WriteByte('\n')
for i, field := range fields {
b.WriteString("- `")
b.WriteString(field)
b.WriteString("`: ")
if i < len(rowItems) {
b.WriteString(markdownInlineValue(rowItems[i]))
}
b.WriteByte('\n')
}
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')
}
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
b.WriteString("Missing records: ")
b.WriteString(missing)
b.WriteByte('\n')
}
return b.String()
}
func renderMissingSingleRecordMarkdown(recordID string, data map[string]interface{}) string {
var b strings.Builder
b.WriteString("Record not found.\n\n")
b.WriteString("- `_record_id`: ")
b.WriteString(markdownInlineValue(recordID))
b.WriteByte('\n')
meta := recordMarkdownMeta(data)
if len(meta) > 0 {
b.WriteString("\nMeta: ")
b.WriteString(strings.Join(meta, "; "))
b.WriteByte('\n')
}
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
b.WriteString("Missing records: ")
b.WriteString(missing)
b.WriteByte('\n')
}
return b.String()
}
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))
}
if missingCount := ignoredFieldsCount(data["record_not_found"]); missingCount > 0 {
meta = append(meta, fmt.Sprintf("record_not_found=%d", missingCount))
}
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 recordNotFoundMarkdown(value interface{}) string {
return strings.Join(markdownListItems(value), ", ")
}
func recordMarkedNotFound(value interface{}, recordID string) bool {
for _, item := range markdownListItems(value) {
if item == recordID {
return true
}
}
return false
}
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,298 @@
// 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 TestRenderRecordGetMarkdownSingleRecordUsesKVLayout(t *testing.T) {
got, err := renderRecordGetMarkdown(map[string]interface{}{
"fields": []interface{}{"Name|Label", "Note"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"- `_record_id`: rec_1",
"- `Name|Label`: A|B",
"- `Note`: line1\nline2",
"Meta: count=1; has_more=false",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordGetMarkdownSingleMissingRecordUsesNotFoundLayout(t *testing.T) {
got, err := renderRecordGetMarkdown(map[string]interface{}{
"fields": []interface{}{"Name", "Note"},
"record_id_list": []interface{}{"rec_missing"},
"data": []interface{}{[]interface{}{nil, nil}},
"record_not_found": []interface{}{"rec_missing"},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"Record not found.",
"- `_record_id`: rec_missing",
"Meta: count=1; has_more=false; record_not_found=1",
"Missing records: rec_missing",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "- `Name`:") {
t.Fatalf("missing record layout should not render business fields:\n%s", got)
}
}
func TestRenderRecordMarkdownIncludesMissingRecords(t *testing.T) {
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_missing"},
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{nil}},
"record_not_found": []interface{}{"rec_missing"},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"Meta: count=2; has_more=false; record_not_found=1",
"Missing records: rec_missing",
} {
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

@@ -7,10 +7,194 @@ import (
"context"
"net/url"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRecordSelectionCount = 200
const maxBatchGetSelectFieldCount = 100
type recordSelection struct {
recordIDs []string
selectFields []string
fromJSON bool
}
type stringListNormalizeOptions struct {
typeError string
emptyError string
itemName string
duplicateName string
limitName string
max int
allowNil bool
allowEmpty bool
}
func validateRecordSelection(runtime *common.RuntimeContext) error {
_, err := resolveRecordSelection(runtime)
return err
}
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
recordIDs := runtime.StrArray("record-id")
fieldIDs := runtime.StrArray("field-id")
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
}
if jsonRaw != "" {
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, jsonRaw, "json")
if err != nil {
return recordSelection{}, err
}
recordIDListValue, ok := body["record_id_list"]
if !ok {
return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
}
recordIDItems, ok := recordIDListValue.([]interface{})
if !ok {
return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
}
normalized, err := normalizeRecordIDs(recordIDItems)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
if err != nil {
return recordSelection{}, err
}
return recordSelection{
recordIDs: normalized,
selectFields: selectFields,
fromJSON: true,
}, nil
}
normalized, err := normalizeRecordIDs(recordIDs)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
if err != nil {
return recordSelection{}, err
}
return recordSelection{
recordIDs: normalized,
selectFields: selectFields,
}, nil
}
func normalizeRecordIDs(values interface{}) ([]string, error) {
return normalizeStringList(values, stringListNormalizeOptions{
typeError: "record selection must be a string array",
emptyError: `provide at least one --record-id, or use --json with "record_id_list"`,
itemName: "record selection item",
duplicateName: "record id",
limitName: "record selection",
max: maxRecordSelectionCount,
})
}
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
if err != nil {
return nil, err
}
if body == nil {
return fromFlags, nil
}
rawJSONFields, ok := body["select_fields"]
if !ok {
return fromFlags, nil
}
if len(fromFlags) > 0 {
return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
}
items, ok := rawJSONFields.([]interface{})
if !ok {
return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
}
if len(items) == 0 {
return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
}
normalized, err := normalizeRecordGetSelectFields(items)
if err != nil {
return nil, err
}
return normalized, nil
}
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
return normalizeStringList(values, stringListNormalizeOptions{
typeError: "field selection must be a string array",
itemName: "field selection item",
duplicateName: "field id",
limitName: "field selection",
max: maxBatchGetSelectFieldCount,
allowNil: true,
allowEmpty: true,
})
}
func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([]string, error) {
var rawItems []interface{}
switch typed := values.(type) {
case nil:
if opts.allowNil {
return nil, nil
}
return nil, common.FlagErrorf(opts.typeError)
case []interface{}:
rawItems = typed
case []string:
rawItems = make([]interface{}, 0, len(typed))
for _, item := range typed {
rawItems = append(rawItems, item)
}
default:
return nil, common.FlagErrorf(opts.typeError)
}
if len(rawItems) == 0 {
if opts.allowEmpty {
return nil, nil
}
return nil, common.FlagErrorf(opts.emptyError)
}
if opts.max > 0 && len(rawItems) > opts.max {
return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
}
seen := make(map[string]int, len(rawItems))
result := make([]string, 0, len(rawItems))
for index, value := range rawItems {
item, ok := value.(string)
if !ok {
return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
}
item = strings.TrimSpace(item)
if item == "" {
return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
}
if first, exists := seen[item]; exists {
return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
}
seen[item] = index + 1
result = append(result, item)
}
return result, nil
}
func recordGetBatchBody(selection recordSelection) map[string]interface{} {
body := map[string]interface{}{
"record_id_list": selection.recordIDs,
}
if len(selection.selectFields) > 0 {
body["select_fields"] = selection.selectFields
}
return body
}
func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
offset := runtime.Int("offset")
if offset < 0 {
@@ -34,11 +218,15 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
}
func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
selection, err := resolveRecordSelection(runtime)
if err != nil {
return common.NewDryRunAPI()
}
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_get").
Body(recordGetBatchBody(selection)).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
Set("record_id", runtime.Str("record-id"))
Set("table_id", baseTableID(runtime))
}
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -90,11 +278,15 @@ func dryRunRecordBatchUpdate(_ context.Context, runtime *common.RuntimeContext)
}
func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
selection, err := resolveRecordSelection(runtime)
if err != nil {
return common.NewDryRunAPI()
}
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_delete").
Body(map[string]interface{}{"record_id_list": selection.recordIDs}).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
Set("record_id", runtime.Str("record-id"))
Set("table_id", baseTableID(runtime))
}
func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -173,6 +365,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,15 +385,29 @@ 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
}
func executeRecordGet(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
selection, err := resolveRecordSelection(runtime)
if err != nil {
return err
}
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_get"), nil, recordGetBatchBody(selection))
data, err := handleBaseAPIResult(result, err, "batch get records")
if err != nil {
return err
}
if runtime.Str("format") == "markdown" {
return outputRecordGetMarkdown(runtime, data)
}
runtime.Out(data, nil)
return nil
}
@@ -213,6 +422,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
}
@@ -272,10 +484,17 @@ func executeRecordBatchUpdate(runtime *common.RuntimeContext) error {
}
func executeRecordDelete(runtime *common.RuntimeContext) error {
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
selection, err := resolveRecordSelection(runtime)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"deleted": true, "record_id": runtime.Str("record-id")}, nil)
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_delete"), nil, map[string]interface{}{
"record_id_list": selection.recordIDs,
})
data, err := handleBaseAPIResult(result, err, "batch delete records")
if err != nil {
return err
}
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

@@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "string_array"
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime

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

@@ -18,6 +18,35 @@ import (
const defaultLocateDocLimit = 10
// maxCommentTotalRunes is the cap on the combined character (rune) count
// across all `reply_elements[].text` fields in a single
// `drive +add-comment` request.
//
// The open-platform `/open-apis/drive/v1/files/{token}/new_comments`
// endpoint returns an opaque `[1069302] Invalid or missing parameters`
// when this is exceeded — no indication that length is the cause or
// which element is at fault.
//
// Empirically (probing the live API):
//
// - 10000 runes in a single text element: OK (10000 ASCII / 30000
// bytes for Chinese / 40000 bytes if all '<' — server counts the
// raw rune count, not byte width and not the post-escape form)
// - 10001 runes in a single text element: [1069302]
// - 5000 + 5000 across two elements (total 10000): OK
// - 5000 + 5001 across two elements (total 10001): [1069302]
//
// So the cap is applied to the *total* across all reply_elements, not
// per element. Splitting an over-the-cap message into multiple text
// elements does NOT help — the server enforces the same limit on the
// sum.
//
// The schema doc currently advertises a 1-1000 character limit, but
// the live API accepts up to 10000 runes; the schema is out of date.
// If this constant ever needs to track a server-side change, re-probe
// with `drive file.comments create_v2` against a fresh docx.
const maxCommentTotalRunes = 10000
type commentDocRef struct {
Kind string
Token string
@@ -604,6 +633,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
}
replyElements := make([]map[string]interface{}, 0, len(inputs))
totalRunes := 0
for i, input := range inputs {
index := i + 1
elementType := strings.TrimSpace(input.Type)
@@ -612,9 +642,27 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
if strings.TrimSpace(input.Text) == "" {
return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index)
}
if utf8.RuneCountInString(input.Text) > 1000 {
return nil, output.ErrValidation("--content element #%d text exceeds 1000 characters", index)
// Measure the raw rune count of the user input — that is what
// the server actually counts. byte width and post-escape form
// don't matter (10000 '<' chars succeed even though they
// expand to 40000 bytes when escaped, and 10000 Chinese chars
// succeed even though they encode as 30000 UTF-8 bytes).
runes := utf8.RuneCountInString(input.Text)
totalRunes += runes
if totalRunes > maxCommentTotalRunes {
return nil, output.ErrWithHint(
output.ExitValidation,
"text_too_long",
fmt.Sprintf("--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements",
totalRunes, index, runes, maxCommentTotalRunes),
fmt.Sprintf("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes),
)
}
// Escape '<' and '>' so the rendered comment displays them as
// literal characters instead of being interpreted as markup
// by Lark's comment renderer. This is independent of the
// length check — the server sees the escaped form, but
// counts characters by the raw input length above.
replyElements = append(replyElements, map[string]interface{}{
"type": "text",
"text": escapeCommentText(input.Text),

View File

@@ -5,11 +5,13 @@ package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func decodeJSONMap(t *testing.T, raw string) map[string]interface{} {
@@ -292,6 +294,186 @@ func TestParseCommentReplyElementsEscapesAngleBrackets(t *testing.T) {
}
}
func TestParseCommentReplyElementsTextLength(t *testing.T) {
t.Parallel()
// Cap is 10000 runes total across all reply_elements text fields,
// empirically derived from the live API. See the comment on
// maxCommentTotalRunes for the probe results.
exactCapASCII := strings.Repeat("a", 10000)
overCapASCII := strings.Repeat("a", 10001)
// Chinese chars cost 3 bytes each in UTF-8 but the server counts
// runes, not bytes — so the cap is the same 10000 here.
exactCapCJK := strings.Repeat("文", 10000)
overCapCJK := strings.Repeat("文", 10001)
// '<' would expand to '&lt;' (4 bytes) under escapeCommentText, but
// since the server counts raw runes the cap is still 10000 chars,
// not 2500. This pins that distinction.
exactCapAngle := strings.Repeat("<", 10000)
overCapAngle := strings.Repeat("<", 10001)
// Two-element split exactly hitting the cap together.
splitFiveK := strings.Repeat("a", 5000)
splitFiveKPlusOne := strings.Repeat("a", 5001)
tests := []struct {
name string
input string
wantErr string
wantHint string // substring of the hint portion; "" means don't check hint
wantCount int // expected parsed element count when no error expected
}{
{
name: "single element exactly at 10000 ASCII chars accepted",
input: `[{"type":"text","text":"` + exactCapASCII + `"}]`,
wantCount: 1,
},
{
name: "single element at 10001 ASCII chars rejected",
input: `[{"type":"text","text":"` + overCapASCII + `"}]`,
wantErr: "totals 10001 characters at element #1",
wantHint: "splitting one long element into multiple smaller text elements does NOT help",
},
{
name: "single element exactly at 10000 chinese chars accepted (server counts runes, not bytes)",
input: `[{"type":"text","text":"` + exactCapCJK + `"}]`,
wantCount: 1,
},
{
name: "single element at 10001 chinese chars rejected",
input: `[{"type":"text","text":"` + overCapCJK + `"}]`,
wantErr: "totals 10001 characters at element #1",
},
{
name: "10000 angle brackets accepted (server counts raw runes, not escaped form)",
input: `[{"type":"text","text":"` + exactCapAngle + `"}]`,
wantCount: 1,
},
{
name: "10001 angle brackets rejected (escape state irrelevant to cap)",
input: `[{"type":"text","text":"` + overCapAngle + `"}]`,
wantErr: "totals 10001 characters at element #1",
},
{
// Pins the multi-element TOTAL cap: two 5000-char elements
// fit together exactly (10000 sum). This is the boundary the
// previous PR's "split into multiple elements" advice
// implied was a workaround — it's actually only valid if
// the sum still fits.
name: "two elements totalling exactly 10000 accepted",
input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveK + `"}]`,
wantCount: 2,
},
{
// Companion to the above and the headline reason the prior
// "split into multiple elements" hint is wrong: 5000+5001
// sums to 10001 which the server rejects with the same
// opaque [1069302], regardless of how many elements it's
// distributed across.
name: "two elements totalling 10001 rejected with index pointing at offending element",
input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveKPlusOne + `"}]`,
wantErr: "totals 10001 characters at element #2",
wantHint: "splitting one long element into multiple smaller text elements does NOT help",
},
{
// Streaming-cap correctness: when an EARLY element by itself
// already overshoots, the index reported is that early
// element (not the last one in the array).
name: "first element over the cap reports index 1",
input: `[{"type":"text","text":"` + overCapASCII + `"},{"type":"text","text":"trailing"}]`,
wantErr: "totals 10001 characters at element #1",
},
{
// mention_user / link elements don't count toward the
// rune cap (their content is ID / URL, not user-visible
// running text). Pin that a moderate text plus a mention
// stays accepted even though the mention adds bytes.
name: "text plus mention_user does not double-count toward cap",
input: `[{"type":"text","text":"` + exactCapASCII + `"},{"type":"mention_user","text":"ou_1234567890abcdef"}]`,
wantCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseCommentReplyElements(tt.input)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil (parsed %d elements)", tt.wantErr, len(got))
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
if tt.wantHint != "" {
// Hint lives on ExitError.Detail.Hint, not err.Error().
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint)
}
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != tt.wantCount {
t.Fatalf("expected %d reply elements, got %d", tt.wantCount, len(got))
}
})
}
}
// TestParseCommentReplyElementsHintForbidsSplitAdvice pins that the
// over-cap hint does NOT recommend splitting into multiple text
// elements as a workaround. An earlier version of this PR shipped
// that advice; live-API probing showed the cap is on the *total* run
// of characters across all reply_elements, so splitting doesn't
// bypass it. If the hint ever drifts back into recommending a split,
// users will be sent down a dead end where their first attempt fails
// pre-flight, their "fixed" attempt also fails server-side, and
// they're stuck.
func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) {
t.Parallel()
_, err := parseCommentReplyElements(`[{"type":"text","text":"` + strings.Repeat("a", 10001) + `"}]`)
if err == nil {
t.Fatal("expected over-cap error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
}
hint := exitErr.Detail.Hint
// The hint must explicitly call out that splitting does NOT help.
if !strings.Contains(hint, "does NOT help") {
t.Errorf("hint must explicitly say splitting does NOT help, got: %q", hint)
}
// Anti-pattern check: the hint must not phrase any "split into
// multiple elements" recommendation as a workaround. Look for the
// previous PR's exact phrasing variants.
for _, banned := range []string{
"split the content across multiple",
"split into multiple text elements",
"renders them as one contiguous comment",
} {
if strings.Contains(hint, banned) {
t.Errorf("hint must not contain the discredited %q advice, got: %q", banned, hint)
}
}
// And it should reference the actual number so callers know the
// budget without having to read the source.
if !strings.Contains(hint, "10000") {
t.Errorf("hint should name the 10000-rune budget, got: %q", hint)
}
}
func TestParseCommentReplyElementsInvalid(t *testing.T) {
t.Parallel()

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

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