Compare commits

..

52 Commits

Author SHA1 Message Date
sammi-bytedance
56ed529c1b fix(im): add im:message scope for user identity send/reply (#237) 2026-04-02 23:28:57 +08:00
liujinkun2025
f67f569e76 feat(drive): support importing documents larger than 20MB (#220)
Change-Id: I445d629c080a5e9834e277d871406d34452bf1be
2026-04-02 22:34:27 +08:00
zhaoshengmeng626
f930d9c52f fix(docs): normalize capitalization in lark-approval skill description (#233)
Lowercase "Approval" to "approval" and uppercase the leading "query" to "Query" so the description follows the same sentence-case convention.
2026-04-02 21:24:06 +08:00
qianzhicheng95
7c3d5b31d5 chore: add v1.0.3 changelog and bump version (#231)
Change-Id: I4201689c6190786822f9bd8fec43532279e4e0c1
2026-04-02 21:10:20 +08:00
zhaoshengmeng626
bf537f8d9c fix:add approval capability to README (#224) 2026-04-02 20:59:33 +08:00
feng zhi hao
10caeb5788 docs(mail): clarify JSON output is directly usable without extra encoding (#228)
Users reported that AI agents sometimes wrote shell scripts to manually
extract and re-decode JSON string fields (e.g. unicode_escape), causing
Chinese character corruption. Add notes to mail skill docs clarifying
that JSON output can be read directly without additional encoding
conversion.
2026-04-02 20:04:21 +08:00
wangzhengkui
6a4dd8dc1b fix(mail): use in-memory keyring in mail scope tests to avoid macOS keychain popups (#212)
Mail scope tests (TestConfirmSendMissingScope*) were calling
auth.SetStoredToken/RemoveStoredToken which accessed the real macOS
keychain via go-keyring, causing persistent popup dialogs when the
master key was missing. Add keyring.MockInit() to swap in an in-memory
backend during tests.
2026-04-02 19:57:24 +08:00
qianzhicheng95
1f3d9e0420 fix: use curl for binary download to support proxy and add npmmirror fallback (#226)
Node.js https.get() does not honor https_proxy/HTTP_PROXY env vars,
causing silent download failures behind firewalls. Switch to curl which
natively supports proxy settings, and add npmmirror.com as a fallback
mirror for regions where GitHub is slow or blocked.

Change-Id: If9ace1e467e46f2a3009610a808bce8d78259e78
2026-04-02 19:49:13 +08:00
zhaoshengmeng626
6692300468 add approve domain (#217) 2026-04-02 18:57:56 +08:00
MaxHuang22
7baba213bc feat: add --jq flag for filtering JSON output (#211)
* feat: add --jq flag for filtering JSON output across all command types

Add jq expression filtering (--jq / -q) to api, service, and shortcut
commands using gojq. Includes early expression validation, mutual
exclusion checks with --output and non-json --format, pagination+jq
aggregation path, and comprehensive test coverage.

* fix: correct gofmt alignment in jq_test.go struct literal


* fix: downgrade gojq to v0.12.17 to keep Go 1.23 compatibility

gojq v0.12.18 requires Go 1.24, which unnecessarily bumped the project
minimum version. v0.12.17 requires only Go 1.21 and provides the same
jq functionality needed.


* refactor: consolidate jq validation and pagination logic

Extract ValidateJqFlags() and PaginateWithJq() shared functions to
eliminate duplicated jq logic across api, service, and shortcut commands.

* fix: reject --jq for non-JSON responses and propagate shortcut jq errors

- HandleResponse now returns a validation error when --jq is used with
  a non-JSON Content-Type instead of silently falling through to binary save.
- Shortcut runtime jq errors are captured in RuntimeContext.outputErr
  and propagated as the command exit code, matching api/service behavior.
2026-04-02 18:36:59 +08:00
wittam-01
725a62879b docs: clarify docs search query usage (#221)
Change-Id: I3108efcaedfefc8c247b0d5d0a97e59695bde11d
2026-04-02 18:36:45 +08:00
iyaozhen
112dd5f6b2 ci: add gitleaks scanning workflow and custom rules (#142) 2026-04-02 16:51:20 +08:00
caojie0621
0f96bdf5e8 fix: normalize escaped sheet range separators (#207)
Accept escaped and full-width sheet/range separators in sheets shortcuts.
Normalize range parsing in the shared sheets helper so read, find, write,
and append handle \!, \!, and ! consistently.
Add regression tests for separator normalization in dry-run paths.
2026-04-02 15:51:22 +08:00
max
102ee51914 feat: add +download shortcut for minutes media download (#101)
* feat: add +download shortcut for minutes media download

* chore: remove accidentally committed test artifacts from shortcuts/vc

* feat: use minute title and auto-detected extension for default download filename

* docs: clarify note_doc_token vs verbatim_doc_token and add cover image guidance

* refactor: resolve default filename from Content-Disposition instead of extra API call

* test: add unit and integration tests for minutes +download shortcut

* fix: add SSRF protection and redirect safety for media download

* feat: add batch download with concurrent execution and SSRF protection

* chore: promote golang.org/x/sync to direct dependency

* fix: resolve copyloopvar and nilerr lint errors

* fix: replace errgroup with WaitGroup to resolve nilerr lint and translate comments to English

* feat: unify --minute-tokens flag, add batch download, token validation, and smart filename resolution

* fix: address PR review — download timeout, UTF-8 truncation, concurrency safety, rate limiting, dedup robustness

* refactor: simplify +download — unify single/batch loop, remove parallel download, merge output flags

* fix(minutes): deduplicate filenames in batch download by prefixing token on collision

* fix(minutes): fix gofmt alignment in downloadOpts struct

* fix(minutes): add transport-level SSRF protection and batch output validation
2026-04-02 15:31:13 +08:00
liujinkun2025
79f43dc337 feat: add drive import, export, move, and task result shortcuts (#194)
Change-Id: I0938dcf587e377afc4ab7133f1e8ff1e2412e566
2026-04-02 14:01:39 +08:00
sammi-bytedance
f231031041 feat: support im message send/reply with uat (#180)
- Add --as user support to +messages-send and +messages-reply
- Add UserScopes (im:message.send_as_user) / BotScopes (im:message:send_as_bot)
- Add DoAPIAsBot to RuntimeContext so file/image uploads always use bot
  identity even when the surrounding command runs as user
- Update skill docs and reference files to reflect user/bot support
- Default identity remains bot (first element of AuthTypes)
2026-04-02 12:12:10 +08:00
wangzhengkui
f68a41163e fix(mail): on-demand scope checks and watch event filtering (#198)
* fix(mail): on-demand scope checks, event filtering, and watch lifecycle

- Remove mail:user_mailbox.folder:read from watch's static Scopes; add
  validateFolderReadScope and validateLabelReadScope that check
  permissions on-demand when listMailboxFolders/listMailboxLabels is
  called (same pattern as validateConfirmSendScope).
- Resolve --mailbox me to real email address via profile API for event
  filtering, preventing other users' mail events from being processed.
  Block startup if resolution fails, with proper error type distinction.
- Add unsubscribe cleanup (guarded by sync.Once) on all exit paths:
  SIGINT/SIGTERM, profile resolution failure, and WebSocket failure.
- Remove bot from AuthTypes since bot tokens cannot subscribe.
- Include profile lookup in dry-run output and update tests.
- Update fetchMailboxPrimaryEmail to return error for diagnostics.
- Update documentation for on-demand scope requirements.

* fix(mail): preserve original error in enhanceProfileError fallback

Return the original error directly for non-permission failures instead
of wrapping with fmt.Errorf, so structured exit codes (ExitNetwork,
ExitAPI) are preserved for scripting.
2026-04-02 10:56:49 +08:00
feng zhi hao
eda2b9cd85 revert: undo auto-resolve local image paths in draft body HTML (#199)
* Revert "fix(mail): clarify that file path flags only accept relative paths (#141)"

This reverts commit 1ffe870dc8.

* Revert "feat(mail): auto-resolve local image paths in draft body HTML (#81) (#139)"

This reverts commit 70c72a2c02.

* Reapply "fix(mail): clarify that file path flags only accept relative paths (#141)"

This reverts commit d465e085b1.
2026-04-01 23:11:30 +08:00
liangshuo-1
a703202ef8 chore: add v1.0.2 changelog and bump version (#192)
Change-Id: Id02603da7916689f79861f543a5e0f261f443753
2026-04-01 21:17:00 +08:00
MaxHuang22
eb8b542f42 feat: add TestGenerateShortcutsJSON and skip redundant meta fetch (#179)
* feat: add TestGenerateShortcutsJSON for registry shortcut export

Add a test that exports all shortcuts as JSON when SHORTCUTS_OUTPUT
env var is set, enabling the registry repo to extract shortcut
metadata without depending on a dump-shortcuts CLI command.
2026-04-01 20:14:19 +08:00
JackZhao10086
d4c051d211 feat: improve OS keychain/DPAPI access error handling for sandbox environments (#173)
* refactor(keychain): improve error handling and consistency across platforms

- Change platformGet to return error instead of empty string
- Add proper error wrapping for keychain operations
- Make master key creation conditional in getMasterKey
- Improve error messages and handling for keychain access
- Update dependent code to handle new error returns

* docs(keychain): improve function documentation and error message

Add detailed doc comments for all platform-specific keychain functions to clarify their purpose and behavior. Also enhance the error hint message to include a suggestion for reconfiguring the CLI when keychain access fails.

* refactor(keychain): reorder operations in platformGet for better error handling

Check for file existence before attempting to read and get master key

* fix(keychain): improve error handling and consistency across platforms.

* fix(keychain): handle corrupted master key case

* fix(keychain): handle I/O errors when reading master key
2026-04-01 17:58:52 +08:00
williamfzc
5621d2e555 feat(ci): refine PR business area labels and introduce skill format check (#148)
* feat(ci): add PR size label pipeline

* chore(ci): make PR label sync non-blocking

* feat(ci): add dry-run mode for PR label sync

* feat(ci): add PR label dry-run samples

* test(ci): update PR label samples with real historical merged PRs

Replaced synthetic or open PR samples with actual merged/closed PRs from the
repository to provide a more accurate reflection of the size label categorization.
Added 4 samples each for sizes S, M, and L covering docs, fixes, ci, and features.

* feat(ci): add high-level area tags for PRs

Based on user feedback, fine-grained domain labels (like `domain/base`) are too detailed for the early stages.
This change adds support for applying `area/*` tags to indicate which important top-level modules a PR touches.

Currently tracked areas:
- `area/shortcuts`
- `area/skills`
- `area/cmd`

Minor modules like docs, ci, and tests are intentionally excluded to keep tags focused on critical architectural components.

* refactor(ci): extract pr-label-sync logic to a dedicated directory

To avoid polluting the root `scripts/` directory, moved `sync_pr_labels.js` and
`sync_pr_labels.samples.json` into a new `scripts/sync-pr-labels/` folder.
Added a dedicated README to document its usage and behavior.
Updated `.github/workflows/pr-labels.yml` to reflect the new path.

* refactor(ci): rename pr label script directory for simplicity

Renamed `scripts/sync-pr-labels/` to `scripts/pr-labels/` to keep directory
names concise. Updated internal references and GitHub workflow files to point
to the new path.

* ci: add GitHub Actions workflow to check skill format

* test(ci): update sample json to include expected_areas

Added `expected_areas` lists to each sample in `samples.json` to reflect
the newly added `area/*` high-level module tagging logic. Allows testing
to accurately check both `size/*` and `area/*` outputs.

* refactor(scripts): move skill format check to isolated directory and add README

* test(scripts): add positive and negative tests for skill format check

* fix(scripts): revert skill changes and downgrade version/metadata checks to warnings

* fix(scripts): completely remove version check and skip lark-shared

* refactor(ci): improve pr-labels script readability and maintainability

- Reorganized code into logical sections with clear comments
- Encapsulated GitHub API interactions into a reusable `GitHubClient` class
- Extracted and centralized classification logic into a pure `evaluateRules` function
- Replaced magic numbers with named constants (`THRESHOLD_L`, `THRESHOLD_XL`)
- Fixed `ROOT` path resolution logic
- Simplified conditional statements and control flow

* ci: fix setup-node version in pr-labels workflow

* tmp

* refactor(ci): replace generic area labels with business-specific ones

- Add PATH_TO_AREA_MAP to map shortcuts/skills paths to business areas (im, vc, ccm, base, mail, calendar, task, contact)

- Replace importantAreas with businessAreas throughout the codebase

- Remove area/shortcuts, area/skills, area/cmd generic labels

- Now generates specific labels like area/im, area/vc, area/ccm, etc.

- Update samples.json expected_areas to match new behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): address PR review feedback for label scripts and workflows

- Add `edited` event to PR labels workflow to trigger on title changes
- Add security warning comment in pr-labels.yml workflow
- Update pr-labels README with latest business area labels
- Exclude `skills/lark-*` paths from low risk doc classification
- Handle renamed files properly in PR path classification
- Fix YAML frontmatter extraction to handle CRLF line endings
- Use precise regex for YAML key validation instead of substring match
- Fix exit code checking logic in skill-format-check test script
- Translate Chinese comments in skill-format-check to English

* fix(skill-format-check): address CodeRabbit review feedback

- Fix frontmatter closing delimiter detection to strictly match '---' using regex, preventing invalid closing tags like '----' from passing.
- Improve test fixture reliability by failing tests immediately if fixture preparation fails, avoiding false positives.

* fix: address review comments from PR 148

- ci: warn when PR label sync fails in job summary
- test(skill-format-check): capture validator output for negative tests
- fix(skill-format-check): catch errors when reading SKILL.md to avoid hard crashes

* fix: add error handling for directory enumeration in skill-format-check

- refactor: use `fs.readdirSync` with `{ withFileTypes: true }` to avoid extra stat calls
- fix: catch and report errors gracefully during skills directory enumeration instead of crashing

* docs(skill-format-check): clarify `metadata` requirement in README

test(pr-labels): add edge case samples for skills paths, CCM multi-paths, and renames

* test(pr-labels): add real PR edge case samples

- use PR #134 to test skill path behaviors
- use PR #57 to test multi-path CCM resolution
- use PR #11 to test track renames cross domains

* refactor(ci): migrate pr labels from area to domain prefix

- Replaced `area/` prefix with `domain/` for PR labeling to align with existing GitHub labels
- Renamed internal constants and variables from `area` to `domain` (e.g. `PATH_TO_AREA_MAP` to `PATH_TO_DOMAIN_MAP`)
- Updated `samples.json` test data to use new `domain/` format and `expected_domains` key
- Added `scripts/pr-labels/test.js` runner script for continuous validation of labeling logic against PR samples
- Corrected expected size label for PR #134 test sample

* test: use execFileSync instead of execSync in pr-labels test script

* fix: resolve target path against process.cwd() instead of __dirname in skill-format-check

* docs: correct label prefix in PR label workflow README

- Updated README.md to reflect the new `domain/` label prefix instead of `area/`

* fix(ci): fix dry-run console output formatting and enforce auth in tests

- Removed duplicate domain array interpolation in printDryRunResult
- Added process.env.GITHUB_TOKEN guard in test.js to prevent ambiguous failures from API rate limits

* fix(ci): ensure PR labels can be applied reliably

- Added `issues: write` permission to pr-labels workflow, which is strictly required by the GitHub REST API to modify labels on pull requests
- Reordered script execution in `index.js` to apply/remove labels on the PR *before* attempting to sync repository-level label definitions (colors/descriptions). The definition sync is now a trailing best-effort step with error catching so transient repo-level API failures don't abort the critical path.

* fix(ci): fix edge cases in pr-label index script

- Added missing `skills/lark-task/` to `PATH_TO_DOMAIN_MAP` to properly detect task domain modifications
- Updated GitHub REST API error checking in `syncLabelDefinition` to reliably match `error.status === 422` rather than loosely checking substring
- Moved token presence check in `main()` to happen before `resolveContext` to avoid triggering unauthenticated 401 API limits when GITHUB_TOKEN is omitted locally

* test(ci): clean up PR label test samples

- Removed duplicate PR entries (#11 and #57) to reduce redundant API calls during testing
- Renamed sample test cases to correctly reflect their expected labels (e.g. `size-l-skill-format-check` -> `size-m-skill-format-check`)

* fix(ci): bootstrap new labels before applying to PRs

- Prior changes correctly made full label sync best-effort, but broke the flow for brand new domains
- GitHub API returns a 422 error if you attempt to attach a label to an Issue/PR that does not exist in the repository
- Added a targeted bootstrap loop to create/sync specifically the labels in `toAdd` before attempting `client.addLabels()`
- Left the remaining global label synchronization as a best-effort trailing action

* test(ci): automate PR label regression testing

- Added a dedicated GitHub Actions workflow (`pr-labels-test.yml`) to automatically run `test.js` against `samples.json` whenever the labeling logic is updated
- Documented local testing instructions in `scripts/pr-labels/README.md`

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 17:45:39 +08:00
kongenpei
17698d5c6a docs: add concise AGENTS development guide (#178)
* docs: add concise AGENTS development guide

* docs: align AGENTS with toolchain and CI license checks

* docs: remove toolchain prerequisite section

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-01 16:00:55 +08:00
feng zhi hao
70c72a2c02 feat(mail): auto-resolve local image paths in draft body HTML (#81) (#139)
* feat(mail): auto-resolve local image paths in draft body HTML (#81)

Allow <img src="./local/path.png" /> in set_body/set_reply_body HTML.
Local file paths are automatically resolved into inline MIME parts with
generated CIDs, eliminating the need to manually pair add_inline with
set_body. Removing or replacing an <img> tag in the body automatically
cleans up or replaces the corresponding MIME inline part.

- Add postProcessInlineImages to unify resolve, validate, and orphan
  cleanup into a single post-processing step
- Extract loadAndAttachInline shared helper to deduplicate addInline
  and resolveLocalImgSrc logic
- Cache resolved paths so the same file is only attached once
- Use whitelist URI scheme detection instead of blacklist
- Remove dead validateInlineCIDAfterApply and
  validateOrphanedInlineCIDAfterApply functions

Closes #81

* fix(mail): harden inline image CID handling

1. Fix imgSrcRegexp to skip attribute names like data-src/x-src that
   contain "src" as a suffix — only match the real src attribute.
2. Sanitize cidFromFileName to replace whitespace with hyphens,
   producing RFC-safe CID tokens (e.g. "my logo.png" → "my-logo").
3. Add CID validation in newInlinePart to reject spaces, tabs, angle
   brackets, and parentheses — fail fast instead of silently producing
   broken inline images in the sent email.

* refactor(mail): use UUID for auto-generated inline CIDs

Replace filename-derived CID generation (cidFromFileName + uniqueCID)
with UUID-based generation. UUIDs contain only [0-9a-f-] characters,
eliminating all RFC compliance risks from special characters, Unicode,
or filename collisions. Same-file deduplication via pathToCID cache
is preserved — multiple <img> tags referencing the same file still
share one MIME part and one CID.

* fix(mail): avoid panic in generateCID by using uuid.NewRandom

uuid.New() calls Must(NewRandom()) which panics if the random source
fails. Replace with uuid.NewRandom() and propagate the error through
resolveLocalImgSrc, so the CLI returns a clear error instead of
crashing in extreme environments.

* fix(mail): restore quote block hint in set_reply_body template description

The auto-resolve PR accidentally dropped "the quote block is
re-appended automatically" from the set_reply_body shape description.
Restore it alongside the new local-path support note.

* fix(mail): add orphan invariant comment and expand regex test coverage

- Add comment in postProcessInlineImages explaining that partially
  attached inline parts on error are cleaned up by the next Apply.
- Add regex test cases: single-quoted src, multiple spaces before src,
  and newline before src.

* fix(mail): use consistent inline predicate and safer HTML part lookup

1. removeOrphanedInlineParts: change condition from
   ContentDisposition=="inline" && ContentID!="" to
   isInlinePart(child) && ContentID!="", matching the predicate used
   elsewhere — parts with only a ContentID (no Content-Disposition)
   are now correctly cleaned up.
2. postProcessInlineImages: use findPrimaryBodyPart instead of
   findPart(snapshot.Body, PrimaryHTMLPartID) to avoid stale PartID
   after ops restructure the MIME tree.

* fix(mail): revert orphan cleanup to ContentDisposition check to protect HTML body

The previous change (d3d1982) broadened the orphan cleanup predicate to
isInlinePart(), which treats any part with a ContentID as inline. This
deletes the primary HTML body when it carries a Content-ID header
(valid in multipart/related), even on metadata-only edits like
set_subject.

Revert to the original ContentDisposition=="inline" && ContentID!=""
condition — only parts explicitly marked as inline attachments are
candidates for orphan removal. Add regression test covering
multipart/related with a Content-ID-bearing HTML body.
2026-04-01 15:47:20 +08:00
kongenpei
d4e83df22c chore: add pull request template (#176)
* add pull request template

* fix: use safe related issue placeholder in PR template

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-01 15:27:08 +08:00
JackZhao10086
4c51a9874d fix: correct URL formatting in login --no-wait output (#169)
* fix: Fix the issue where the URL returned by the "lark-cli auth login --no-wait" command contains \u0026

* style: fix indentation and whitespace in error handling code

* fix(auth): handle JSON encoding errors in login output

* docs(cmd/auth): add comment for authLoginRun function
2026-04-01 13:58:47 +08:00
kongenpei
6463ab13c9 ci: make pkg.pr.new comment flow fork-safe (#170)
* ci: make pkg.pr.new comment flow fork-safe

* ci: harden trusted comment workflow inputs

* ci: skip comment steps when payload artifact is missing

* ci: use artifact PR number when workflow_run pull_requests is empty

* ci: allow PR comment workflow to write pull requests

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-01 13:57:28 +08:00
kongenpei
c4851a5c45 ci: improve pkg.pr.new install comment clarity (#168)
* ci: improve pkg.pr.new install comment clarity

* ci: add emojis to pkg.pr.new install comment headings

* ci: avoid hard fail when PR head metadata is missing

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-01 11:15:54 +08:00
kongenpei
bdd39b0196 ci: add pkg.pr.new PR preview workflow (#152)
* ci: publish PR preview builds to pkg.pr.new

* chore: limit pkg-pr-new build targets to 3 platforms

* ci: use node lts in pkg-pr-new workflow

* chore: trim pkg-pr-new to single target to fit size limit

* ci: publish pkg-pr-new package path without --bin

* ci: disable compact pkg-pr-new urls for fork previews

* ci: post minimal npm -g pkg-pr-new install comment

* chore: enable windows amd64 build for pkg-pr-new

* ci: format pkg-pr-new install comment as markdown code block

* ci: tweak pkg-pr-new comment wording

* ci: pin github-script and paginate PR comments

* chore: enable pkg PR build targets

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-03-31 21:26:38 +08:00
feng zhi hao
1ffe870dc8 fix(mail): clarify that file path flags only accept relative paths (#141) 2026-03-31 20:36:37 +08:00
calendar-assistant
5da3075646 feat(calendar): implement rsvp shortcut (#151)
Change-Id: I96170f024f1c8bb6f44de752961e58e5fec61644
2026-03-31 20:19:24 +08:00
qianzhicheng95
8fc7e12f9e docs: add v1.0.1 changelog (#150)
Change-Id: Ie4751db5ae19689c49deac69007516bf381233b3

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:14:42 +08:00
MaxHuang22
27139a0919 feat: add automatic CLI update detection and notification (#144)
Add non-blocking update check that queries the npm registry for the
latest @larksuite/cli version. Results are cached locally (24h TTL)
to avoid repeated network requests.

When a newer version is detected, a `_notice.update` field is injected
into all JSON output envelopes (success, error, and shortcut responses),
enabling AI agents and scripts to surface upgrade prompts.

Key changes:
- New `internal/update` package: registry fetch, semver compare, cache
- Async check in root command (cache-first, then background refresh)
- `_notice` field added to Envelope/ErrorEnvelope structs
- `PrintJson` injects notice into map-based envelopes with "ok" key
- `doctor` command gains cli_version and cli_update checks
- Suppressed for CI, DEV builds, shell completion, and git-describe versions
2026-03-31 19:01:39 +08:00
qianzhicheng95
c35b1ae2c5 feat: add npm publish job to release workflow (#145)
* feat: add npm publish job to release workflow

Change-Id: Ibfae2af6bd2aabf09936c96d21964af98b77c127
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: bump package version to 1.0.1

Change-Id: Ifb58789be5621ab4979b5fe60e0e30042e07fea8
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:55:52 +08:00
zgz2048
c8341bbd7c docs(base): clarify field description usage in json (#90)
* docs(base): clarify field description usage in json

* chore(base docs): clarify field description usage

* docs(base): add description to field schema
2026-03-31 16:46:40 +08:00
jackie3927
634adfc745 feat: support auto extension for downloads (#16) 2026-03-31 14:01:50 +08:00
vaxin
62d8681b0b docs: update Base description to include all capabilities (#61)
Add workflows, forms, roles & permissions to the Base feature description
across READMEs and service registry to accurately reflect full coverage.

Co-authored-by: dengfanxin <dengfanxin.dfx@bytedance.com>
2026-03-31 13:32:45 +08:00
evandance
a2656e1385 feat:remove useless files (#131) 2026-03-31 12:31:10 +08:00
91-enjoy
8bd5049ebe feat: normalize markdown message send/reply output (#28)
* feat(IM):  im markdown send/reply

Change-Id: I6b53eb2207d7c2393d3c7d108df3ba197b9eae46

* add Resolve content type

Change-Id: I71a0cdb8b500ca1496b23fede1f2b2617f16ec63
2026-03-31 01:36:52 +08:00
YangJunzhou-01
69bcdd9e35 feat: add auto-pagination to messages search and update lark-im docs (#30)
Change-Id: Ic50e891d2385c2e3ac902cd89d95c3db99f94050
2026-03-30 23:00:41 +08:00
Schumi Lin
9b933f1a20 docs: add official badge to distinguish from third-party Lark CLI tools (#103)
* docs: add official badge and maintainer attribution to README

Many third-party Feishu/Lark CLI tools exist, causing confusion
about which is the official one. Add an "Official" badge and a
blockquote clearly stating this repo is maintained by the
Lark/Feishu Open Platform team at larksuite.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: use 飞书官方 CLI 工具 in Chinese README

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: merge official attribution into description, drop Open Platform wording

Remove the separate blockquote and fold official status into the
main description line. Also remove "Open Platform" — this is
the Lark/Feishu CLI tool, not an "Open Platform CLI tool".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: remove self-made Official badge

No major official repo (OpenAI, Anthropic, Stripe, AWS, HashiCorp)
uses a custom shields.io "Official" badge — anyone can make one, so
it signals nothing. The org namespace (larksuite/) and the "official"
wording in the description are sufficient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:15:42 +08:00
kongenpei
8e24166d90 fix(base): use base history read scope for record history list (#96)
Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-03-30 19:40:17 +08:00
feng zhi hao
ecf3209c52 fix: remove sensitive send scope from reply and forward shortcuts (#92)
* fix: remove sensitive send scope from reply and forward shortcuts

Remove mail:user_mailbox.message:send from the required scopes of
+reply, +reply-all, and +forward shortcuts. This scope is sensitive
and may not be granted, while these shortcuts default to saving
drafts and do not strictly require it.

* fix: validate send scope dynamically when --confirm-send is set

Add validateConfirmSendScope() to check mail:user_mailbox.message:send
in the Validate phase when --confirm-send is used, preventing the
"draft created but send failed" scenario. Add regression tests for
+reply, +reply-all, and +forward.
2026-03-30 18:19:11 +08:00
evandance
a13bee8fda fix: resolve silent failure in lark-cli api error output (#39) (#85)
MarkRaw previously skipped both enrichPermissionError and
WriteErrorEnvelope, causing api command to exit 1 with no output
on API errors. Now MarkRaw only skips enrichPermissionError while
WriteErrorEnvelope always runs, ensuring stderr error envelope is
always written.

- Simplify apiRun MarkRaw logic (remove unnecessary IsJSONContentType check)
- Update existing tests to match new behavior
- Add 5 e2e tests covering api/service/shortcut error output
2026-03-30 17:19:24 +08:00
liangshuo-1
e5a83f5eaa ci: improve CI workflows and add golangci-lint config (#71)
* ci: improve CI workflows and add golangci-lint config

- Add path filters to avoid unnecessary CI runs on non-Go changes
- Use go-version-file instead of hardcoded Go version
- Unify runners to ubuntu-latest
- Consolidate staticcheck/vet into golangci-lint with curated linter set
- Add go mod tidy check, govulncheck, and dependency license check
- Enable race detector in coverage, increase test timeout to 5m
- Add build verification step to tests workflow
- Add .codecov.yml with patch coverage target (60%)
- Add .golangci.yml (v2) with security and correctness linters

Change-Id: I409beb21cc1f1568ff47739c0a00f6214c10a0dd

* ci: replace Codecov upload with GitHub Job Summary coverage report

- Remove Codecov action dependency and CODECOV_TOKEN usage
- Generate coverage report using go tool cover and display in Job Summary
- Rename job from 'codecov' to 'coverage'
- Remove .codecov.yml from paths filter

Change-Id: Ib65dab6c4d7117c3300a9ea31eb1550537c72f88

* ci: trigger lint workflow

Change-Id: Ic1c492dd339f5460d2be2971ac65ea8f99e524eb

* ci: replace golangci-lint action with go run to avoid action whitelist restriction

Change-Id: I87274abf9780eb8b6350e98a27302ec5acc2a2e5

* ci: replace golangci-lint action with go run, keep incremental lint via --new-from-rev

Change-Id: I3d4a13cfd7b6c02e4098b04b8533a7248185c077

* ci: add fetch-depth 0 to lint checkout for incremental lint to work

Change-Id: I112279c5ec06dc0aa3aa7e01d564ea27fbd20533

* ci: disable errcheck linter due to high volume of existing violations

Change-Id: Iec57e8fbe42699f687d931d9dde2f879f2ae5b02

* ci: align golangci-lint config with GitHub CLI, make govulncheck non-blocking

- Add exptostd, gocheckcompilerdirectives, gochecksumtype, gomoddirectives linters
- Move gosec, staticcheck, errname, errorlint, misspell to TODO for later enablement
- Remove G104 exclusion (errcheck is disabled)
- Make govulncheck continue-on-error until Go version is upgraded

Change-Id: I330ece4f202229aee1e2f50790f6b22738704c05

* ci: fix go-licenses module path for v2

Change-Id: Ifd018ebe79cd18402171417b1b73313af2d23c6d
2026-03-30 11:09:31 +08:00
TimZhong
d2ad5e4def docs: rename user-facing Bitable references to Base (#11) 2026-03-29 00:00:52 +08:00
TimZhong
511c24bd95 docs: add star history chart to readmes (#12) 2026-03-29 00:00:14 +08:00
liuxinyanglxy
62ad335b26 docs: simplify installation steps by merging CLI and Skills into one section (#26)
Combine the separate "Install CLI" and "Install AI Agent Skills" sections
into a single "Install" section for both human and AI Agent quick start
guides. Renumber the AI Agent steps accordingly.

Change-Id: I4ac10538d912a8889f52ea5a85e757d3e8bad21e
2026-03-28 17:33:24 +08:00
liuxinyanglxy
d4d4f32ec6 docs: add npm version badge and improve AI agent tip wording (#23)
Add npm version badge to both README files for better package
discoverability. Reword the Quick Start tip to directly address
AI assistants instead of human users.

Change-Id: I9fb4252e4a7bde4ab6644c6ca6e63dc5d34b6f0c

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:29:42 +08:00
Schumi Lin
aac94ceb5c docs: emphasize Skills installation as required for AI Agents (#19)
* docs: emphasize Skills installation as required step for AI Agents

Split the AI Agent quick start from a single code block into separate
labeled steps, and add a prominent note explaining that without Skills
the Agent cannot discover available commands or parameters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: position Skills installation as the core step of the setup

Reword the Skills step to convey that it is the most important part of
the entire setup — the CLI is just a binary, Skills are what give the
Agent the knowledge to operate Lark.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: tone down Skills step wording, keep it concise

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:45:20 +08:00
liangshuo-1
2345b98d20 Merge pull request #3 from schumilin/fix/readme-install-clarification
docs: clarify install methods and add source build steps
2026-03-28 11:43:44 +08:00
schumilin
ccbf4a0bd6 docs: clarify install methods as alternatives and add source build steps
Closes larksuite/cli#2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:41:25 +08:00
164 changed files with 11931 additions and 616 deletions

8
.codecov.yml Normal file
View File

@@ -0,0 +1,8 @@
coverage:
status:
project:
default:
informational: true
patch:
default:
target: 60%

16
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,16 @@
## Summary
<!-- Briefly describe the motivation and scope of this change in 1-3 sentences. -->
## Changes
<!-- List the main changes in this PR. -->
- Change 1
- Change 2
## Test Plan
<!-- Describe how this change was verified. -->
- [ ] Unit tests pass
- [ ] Manual local verification confirms the `lark xxx` command works as expected
## Related Issues
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
- None

View File

@@ -2,22 +2,32 @@ name: Coverage
on:
push:
branches: [ main ]
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/coverage.yml
pull_request:
branches: [ main ]
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/coverage.yml
permissions:
contents: read
jobs:
codecov:
runs-on: ubuntu-22.04
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
@@ -27,10 +37,18 @@ jobs:
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: go test -coverprofile=coverage.txt -covermode=atomic ./...
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
with:
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Generate coverage report
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY

28
.github/workflows/gitleaks.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Gitleaks
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
gitleaks:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
# GITHUB_TOKEN is provided automatically by GitHub Actions.
# GITLEAKS_KEY must be configured as a repository secret.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}

View File

@@ -2,43 +2,36 @@ name: Lint
on:
push:
branches: [ main ]
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
pull_request:
branches: [ main ]
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
permissions:
contents: read
jobs:
staticcheck:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Run staticcheck
uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1
with:
install-go: false
golangci-lint:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
@@ -47,26 +40,21 @@ jobs:
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Ensure go.mod and go.sum are tidy
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
- name: Run golangci-lint
uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6
with:
version: latest
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
vet:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Run govulncheck
continue-on-error: true # informational until Go version is upgraded
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Run go vet
run: go vet ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown

149
.github/workflows/pkg-pr-new-comment.yml vendored Normal file
View File

@@ -0,0 +1,149 @@
name: PR Preview Package Comment
on:
workflow_run:
workflows: ["PR Preview Package"]
types: [completed]
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Check comment payload artifact
id: payload
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runId = context.payload.workflow_run?.id;
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
per_page: 100,
});
const found = Boolean(
data.artifacts?.some((artifact) => artifact.name === "pkg-pr-new-comment-payload")
);
core.setOutput("found", found ? "true" : "false");
if (!found) {
core.notice("No comment payload artifact found for this run; skipping comment.");
}
- name: Download comment payload
if: steps.payload.outputs.found == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: pkg-pr-new-comment-payload
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Comment install command
if: steps.payload.outputs.found == 'true'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const fs = require("fs");
const payload = JSON.parse(fs.readFileSync("pkg-pr-new-comment-payload.json", "utf8"));
const url = payload?.url;
const payloadPr = payload?.pr;
const sourceRepo = payload?.sourceRepo;
const sourceBranch = payload?.sourceBranch;
if (!Number.isInteger(payloadPr)) {
throw new Error(`Invalid PR number in artifact payload: ${payloadPr}`);
}
if (payloadPr <= 0) {
throw new Error(`Invalid PR number in artifact payload: ${payloadPr}`);
}
const issueNumber = payloadPr;
const runPrNumber = context.payload.workflow_run?.pull_requests?.[0]?.number;
if (Number.isInteger(runPrNumber) && runPrNumber !== issueNumber) {
throw new Error(
`PR number mismatch between workflow_run (${runPrNumber}) and artifact payload (${issueNumber})`,
);
}
if (typeof url !== "string" || url.trim() !== url || /[\u0000-\u001F\u007F]/.test(url)) {
throw new Error(`Invalid package URL in payload: ${url}`);
}
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch {
throw new Error(`Invalid package URL in payload: ${url}`);
}
if (parsedUrl.protocol !== "https:" || parsedUrl.hostname !== "pkg.pr.new") {
throw new Error(`Invalid package URL in payload: ${url}`);
}
const safeRepoPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const safeBranchPattern = /^[A-Za-z0-9._\/-]+$/;
const hasSkillSource =
typeof sourceRepo === "string" &&
typeof sourceBranch === "string" &&
safeRepoPattern.test(sourceRepo) &&
safeBranchPattern.test(sourceBranch);
const skillSection = hasSkillSource
? [
"",
"### 🧩 Skill update",
"",
"```bash",
`npx skills add ${sourceRepo}#${sourceBranch} -y -g`,
"```",
]
: [
"",
"### 🧩 Skill update",
"",
"_Unavailable for this PR because source repo/branch metadata is missing._",
];
const body = [
"<!-- pkg-pr-new-install-guide -->",
"## 🚀 PR Preview Install Guide",
"",
"### 🧰 CLI update",
"",
"```bash",
`npm i -g ${url}`,
"```",
...skillSection,
].join("\n");
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
const existing = comments.find((comment) =>
comment.user?.login === "github-actions[bot]" &&
typeof comment.body === "string" &&
comment.body.includes("<!-- pkg-pr-new-install-guide -->")
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
}

71
.github/workflows/pkg-pr-new.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: PR Preview Package
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: [main]
permissions:
contents: read
jobs:
publish:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
with:
node-version: lts/*
- name: Build preview package
run: ./scripts/build-pkg-pr-new.sh
- name: Publish to pkg.pr.new
run: npx pkg-pr-new publish --no-compact --json output.json --comment=off ./.pkg-pr-new
- name: Build comment payload
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
SOURCE_REPO: ${{ github.event.pull_request.head.repo.full_name }}
SOURCE_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
node <<'NODE'
const fs = require("fs");
const output = JSON.parse(fs.readFileSync("output.json", "utf8"));
const url = output?.packages?.[0]?.url;
if (!url) throw new Error("No package URL found in output.json");
if (!url.startsWith("https://pkg.pr.new/")) {
throw new Error(`Unexpected package URL: ${url}`);
}
const pr = Number(process.env.PR_NUMBER);
if (!Number.isInteger(pr) || pr <= 0) {
throw new Error(`Invalid PR_NUMBER: ${process.env.PR_NUMBER}`);
}
const payload = {
pr,
url,
sourceRepo: process.env.SOURCE_REPO || "",
sourceBranch: process.env.SOURCE_BRANCH || "",
};
fs.writeFileSync(
"pkg-pr-new-comment-payload.json",
JSON.stringify(payload),
);
NODE
- name: Upload comment payload
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pkg-pr-new-comment-payload
path: pkg-pr-new-comment-payload.json

31
.github/workflows/pr-labels-test.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Test PR Label Logic
on:
push:
branches: [main]
paths:
- "scripts/pr-labels/**"
- ".github/workflows/pr-labels-test.yml"
pull_request:
branches: [main]
paths:
- "scripts/pr-labels/**"
- ".github/workflows/pr-labels-test.yml"
permissions:
contents: read
jobs:
test-pr-labels:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run PR label regression tests
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/pr-labels/test.js

43
.github/workflows/pr-labels.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: PR Labels
on:
pull_request_target:
# NOTE: This event runs with base-branch code and write permissions.
# Do NOT add `ref: github.event.pull_request.head.sha` to the checkout step,
# as that would execute untrusted PR code with elevated access.
types:
- opened
- edited
- reopened
- synchronize
- ready_for_review
permissions:
contents: read
pull-requests: write
issues: write
jobs:
sync-pr-labels:
if: ${{ github.event.pull_request.state == 'open' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Sync managed PR labels
id: sync_pr_labels
# Labeling is best-effort and must not block PR merges.
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/pr-labels/index.js
- name: Warn when label sync fails
if: ${{ always() && steps.sync_pr_labels.outcome == 'failure' }}
run: |
echo "::warning::PR label sync failed; labels may be stale."
echo "⚠️ PR label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"

View File

@@ -33,3 +33,19 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-npm:
needs: goreleaser
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -0,0 +1,32 @@
name: Skill Format Check
on:
push:
branches: [main]
paths:
- "skills/**"
- "scripts/skill-format-check/**"
- ".github/workflows/skill-format-check.yml"
pull_request:
branches: [main]
paths:
- "skills/**"
- "scripts/skill-format-check/**"
- ".github/workflows/skill-format-check.yml"
permissions:
contents: read
jobs:
check-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run Skill Format Check
run: node scripts/skill-format-check/index.js

View File

@@ -2,22 +2,32 @@ name: Tests
on:
push:
branches: [ main ]
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
pull_request:
branches: [ main ]
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
permissions:
contents: read
jobs:
unit-test:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
@@ -27,4 +37,7 @@ jobs:
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=30s ./cmd/... ./internal/... ./shortcuts/...
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
- name: Build
run: go build -v ./...

4
.gitignore vendored
View File

@@ -30,3 +30,7 @@ test_scripts/
tests/mail/reports/
/log/
# Generated / test artifacts
internal/registry/meta_data.json
cmd/api/download.bin

16
.gitleaks.toml Normal file
View File

@@ -0,0 +1,16 @@
title = "lark-cli gitleaks config"
[extend]
useDefault = true
[[rules]]
id = "lark-bot-app-id"
description = "Detect Lark bot app ids"
regex = '''\bcli_[a-z0-9]{16}\b'''
keywords = ["cli_"]
[[rules]]
id = "lark-session-token"
description = "Detect Lark session tokens"
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
keywords = ["XN0YXJ0-", "-WVuZA"]

66
.golangci.yml Normal file
View File

@@ -0,0 +1,66 @@
version: "2"
run:
timeout: 5m
linters:
default: none
enable:
- asasalint # checks for pass []any as any in variadic func(...any)
- asciicheck # checks that code does not contain non-ASCII identifiers
- bidichk # checks for dangerous unicode character sequences
- bodyclose # checks whether HTTP response body is closed successfully
- copyloopvar # detects places where loop variables are copied
- durationcheck # checks for two durations multiplied together
- exptostd # detects functions from golang.org/x/exp/ replaceable by std
- fatcontext # detects nested contexts in loops
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
- gochecksumtype # checks exhaustiveness on Go "sum types"
- gocritic # diagnostics for bugs, performance and style
- gomoddirectives # checks for replace, retract, and exclude in go.mod
- goprintffuncname # checks that printf-like functions end with f
- govet # reports suspicious constructs
- ineffassign # detects ineffective assignments
- nilerr # finds code that returns nil even if error is not nil
- nolintlint # reports ill-formed nolint directives
- nosprintfhostport # checks for misuse of Sprintf to construct host:port
- reassign # checks that package variables are not reassigned
- unconvert # removes unnecessary type conversions
- unused # checks for unused constants, variables, functions and types
# To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors
# - errname # checks that error types are named XxxError
# - errorlint # checks error wrapping best practices
# - gosec # security-oriented linter
# - misspell # finds commonly misspelled English words
# - staticcheck # comprehensive static analysis
exclusions:
paths:
- generated
rules:
- path: _test\.go$
linters:
- bodyclose
- gocritic
settings:
gocritic:
disabled-checks:
- appendAssign
- hugeParam
disabled-tags:
- style
govet:
enable:
- httpresponse
formatters:
enable:
- gofmt
- goimports
issues:
max-issues-per-linter: 0
max-same-issues: 0

33
AGENTS.md Normal file
View File

@@ -0,0 +1,33 @@
# AGENTS.md
Concise maintainer/developer guide for building, testing, and opening high-quality PRs in this repo.
## Goal (pick one per PR)
- Make CLI better: improve UX, error messages, help text, flags, and output clarity.
- Improve reliability: fix bugs, edge cases, and regressions with tests.
- Improve developer velocity: simplify code paths, reduce complexity, keep behavior explicit.
- Improve quality gates: strengthen tests/lint/checks without adding heavy process.
## Fast Dev Loop
1. `make build` (runs `python3 scripts/fetch_meta.py` first)
2. `make unit-test` (required before PR)
3. Run changed command(s) manually via `./lark-cli ...`
## Pre-PR Checks (match CI gates)
1. `make unit-test`
2. `go mod tidy` (must not change `go.mod`/`go.sum`)
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
5. Optional full local suite: `make test` (vet + unit + integration)
## Test/Check Commands
- Unit: `make unit-test`
- Integration: `make integration-test`
- Full: `make test`
- Vet only: `make vet`
- Coverage (local): `go test -race -coverprofile=coverage.txt -covermode=atomic ./...`
## Commit/PR Rules
- Use Conventional Commits in English: `feat: ...`, `fix: ...`, `docs: ...`, `ci: ...`, `test: ...`, `chore: ...`, `refactor: ...`
- Keep PR title in the same Conventional Commit format (squash merge keeps it).
- Before opening a real PR, draft/fill description from `.github/pull_request_template.md` and ensure Summary/Changes/Test Plan are complete.
- Never commit secrets/tokens/internal sensitive data.

View File

@@ -2,6 +2,88 @@
All notable changes to this project will be documented in this file.
## [v1.0.3] - 2026-04-02
### Features
- Add `--jq` flag for filtering JSON output (#211)
- Add `+download` shortcut for minutes media download (#101)
- Add drive import, export, move, and task result shortcuts (#194)
- Support im message send/reply with uat (#180)
- Add approve domain (#217)
### Bug Fixes
- **mail**: Use in-memory keyring in mail scope tests to avoid macOS keychain popups (#212)
- **mail**: On-demand scope checks and watch event filtering (#198)
- Use curl for binary download to support proxy and add npmmirror fallback (#226)
- Normalize escaped sheet range separators (#207)
### Documentation
- **mail**: Clarify JSON output is directly usable without extra encoding (#228)
- Clarify docs search query usage (#221)
### CI
- Add gitleaks scanning workflow and custom rules (#142)
## [v1.0.2] - 2026-04-01
### Features
- Improve OS keychain/DPAPI access error handling for sandbox environments (#173)
- **mail**: Auto-resolve local image paths in draft body HTML (#139)
### Bug Fixes
- Correct URL formatting in login `--no-wait` output (#169)
### Documentation
- Add concise AGENTS development guide (#178)
### CI
- Refine PR business area labels and introduce skill format check (#148)
### Chore
- Add pull request template (#176)
## [v1.0.1] - 2026-03-31
### Features
- Add automatic CLI update detection and notification (#144)
- Add npm publish job to release workflow (#145)
- Support auto extension for downloads (#16)
- Remove useless files (#131)
- Normalize markdown message send/reply output (#28)
- Add auto-pagination to messages search and update lark-im docs (#30)
### Bug Fixes
- **base**: Use base history read scope for record history list (#96)
- Remove sensitive send scope from reply and forward shortcuts (#92)
- Resolve silent failure in `lark-cli api` error output (#85)
### Documentation
- **base**: Clarify field description usage in json (#90)
- Update Base description to include all capabilities (#61)
- Add official badge to distinguish from third-party Lark CLI tools (#103)
- Rename user-facing Bitable references to Base (#11)
- Add star history chart to readmes (#12)
- Simplify installation steps by merging CLI and Skills into one section (#26)
- Add npm version badge and improve AI agent tip wording (#23)
- Emphasize Skills installation as required for AI Agents (#19)
- Clarify install methods as alternatives and add source build steps
### CI
- Improve CI workflows and add golangci-lint config (#71)
## [v1.0.0] - 2026-03-28
### Initial Release
@@ -27,7 +109,7 @@ Built-in shortcuts for commonly used Lark APIs, enabling concise commands like `
- **Drive** — Upload, download, and manage cloud documents.
- **Docs** — Work with Lark documents.
- **Sheets** — Interact with spreadsheets.
- **Base (Bitable)** — Manage multi-dimensional tables.
- **Base** — Manage multi-dimensional tables.
- **Calendar** — Create and manage calendar events.
- **Mail** — Send and manage emails.
- **Contact** — Look up users and departments.
@@ -54,4 +136,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3
[v1.0.2]: https://github.com/larksuite/cli/releases/tag/v1.0.2
[v1.0.1]: https://github.com/larksuite/cli/releases/tag/v1.0.1
[v1.0.0]: https://github.com/larksuite/cli/releases/tag/v1.0.0

28
CLA.md
View File

@@ -1,28 +0,0 @@
> Thank you for your interest in open source projects hosted or managed by ByteDance Ltd. and/or its Affiliates ("**ByteDance**") . In order to clarify the intellectual property license granted with Contributions from any person or entity, ByteDance must have a Contributor License Agreement ("**CLA**") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of ByteDance and its users; it does not change your rights to use your own Contributions for any other purpose.
> If you are an individual making a submission on your own behalf, you should accept the Individual Contributor License Agreement. If you are making a submission on behalf of a legal entity (the “**Corporation**”), you should sign the separation Corporate Contributor License Agreement.
**ByteDance Individual Contributor License Agreement v1.** **1**
By clicking “Accept” on this page, You accept and agree to the following terms and conditions for Your present and future Contributions submitted to ByteDance. Except for the license granted herein to ByteDance and recipients of software distributed by ByteDance, You reserve all right, title, and interest in and to Your Contributions.
1.Definitions.
"Affiliate" shall mean an entity that Controls, is Controlled by, or is under common Control with You or ByteDance, respectively (but only as long as such Control exists).
"Control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to ByteDance for inclusion in, or documentation of, any of the products owned or managed by ByteDance (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to ByteDance or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, ByteDance for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with ByteDance. For the avoidance of doubt, the Corporation making a Contribution and all of its Affiliates are considered to be a single Contributor and this CLA shall apply to Contributions Submitted by the Corporation or any of its Affiliates.
2.Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
3.Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
4.You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to ByteDance, or that your employer has executed a separate Corporate CLA with ByteDance.
5.You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
6.You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
7.Should You wish to submit work that is not Your original creation, You may submit it to ByteDance separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
8.You agree to notify ByteDance of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
9.You agree that contributions to Projects and information about contributions may be maintained indefinitely and disclosed publicly, including Your name and other information that You submit with your submission.
10.This Agreement is the entire agreement and understanding between the parties, and supersedes any and all prior agreements, understandings or communications, written or oral, between the parties relating to the subject matter hereof. This Agreement may be assigned by ByteDance.
[ByteDance Corporate Contributor License Agreement v1.1](./ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf)
This version of the Contributor License Agreement allows a legal entity (the “Corporation”) to submit Contributions to the applicable project.
ByteDance Corporate Contributor License Agreement v1.1.pdf
A person authorized to sign legal documents on behalf of your employer (usually a VP or higher) must sign the Contributor Agreement on behalf of the employer.
If you have not already signed this agreement, please complete and sign, then scan and email a pdf file of this Agreement to opensource-cla@bytedance.com. Please read this document carefully before signing and keep a copy for your records.
If you need to update your CLA, please email <opensource-cla@bytedance.com> from the email address associated with your individual or corporate information.

137
README.md
View File

@@ -2,17 +2,18 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-blue.svg)](https://go.dev/)
[![npm version](https://img.shields.io/npm/v/@larksuite/cli.svg)](https://www.npmjs.com/package/@larksuite/cli)
[中文版](./README.zh.md) | [English](./README.md)
A command-line tool for [Lark/Feishu](https://www.larksuite.com/) Open Platform — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 19 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, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 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** — 19 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 11 business domains, 200+ curated commands, 19 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 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
@@ -21,19 +22,20 @@ A command-line tool for [Lark/Feishu](https://www.larksuite.com/) Open Platform
## Features
| Category | Capabilities |
| ------------- | ----------------------------------------------------------------------------------- |
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
| 💬 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 |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
| 👤 Contact | Search users by name/email/phone, get user profiles |
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
| Category | Capabilities |
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
| 💬 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 |
| 📊 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 |
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
| 👤 Contact | Search users by name/email/phone, get user profiles |
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
## Installation & Quick Start
@@ -46,31 +48,32 @@ Before you start, make sure you have:
### Quick Start (Human Users)
> **Tip:** If you have an AI Agent, you can hand this README to it and let the AI handle installation and setup — jump to [Quick Start (AI Agent)](#quick-start-ai-agent).
> **Note for AI assistants:** If you are an AI Agent helping the user with installation, jump directly to [Quick Start (AI Agent)](#quick-start-ai-agent), which contains all the steps you need to complete.
#### Install CLI
#### Install
**From npm (recommended):**
Choose **one** of the following methods:
**Option 1 — From npm (recommended):**
```bash
# Install CLI
npm install -g @larksuite/cli
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
```
**From source:**
**Option 2 — From source:**
Requires Go `v1.23`+ and Python 3.
```bash
git clone https://github.com/larksuite/cli.git
cd cli
make install
```
#### Install AI Agent Skills
[Skills](./skills/) are structured instruction documents that enable AI Agents to use this CLI:
```bash
# Install all skills to current directory
npx skills add larksuite/cli -y
# Install all skills globally
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
```
@@ -91,48 +94,62 @@ lark-cli calendar +agenda
> The following steps are for AI Agents. Some steps require the user to complete actions in a browser.
**Step 1 — Install**
```bash
# 1. Install CLI
# Install CLI
npm install -g @larksuite/cli
# 2. Install Skills (enables AI Agent to use this CLI)
npx skills add larksuite/cli --all -y
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
```
# 3. Configure app credentials
# Important: run this command in the background. It will output an authorization URL — extract it and send it to the user. The command exits automatically after the user completes the setup in browser.
**Step 2 — Configure app credentials**
> Run this command in the background. It will output an authorization URL — extract it and send it to the user. The command exits automatically after the user completes the setup in the browser.
```bash
lark-cli config init --new
```
# 4. Login
# Same as above: run in the background, extract the authorization URL and send it to the user.
**Step 3 — Login**
> Same as above: run in the background, extract the authorization URL and send it to the user.
```bash
lark-cli auth login --recommend
```
# 5. Verify
**Step 4 — Verify**
```bash
lark-cli auth status
```
## Agent Skills
| Skill | Description |
| ------------------------------- | ------------------------------------------------------------------------------------- |
| Skill | Description |
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
| `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-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
| `lark-contact` | Search users by name/email/phone, get user profiles |
| `lark-wiki` | Knowledge spaces, nodes, documents |
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
| `lark-skill-maker` | Custom skill creation framework |
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
| `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-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
| `lark-contact` | Search users by name/email/phone, get user profiles |
| `lark-wiki` | Knowledge spaces, nodes, documents |
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
| `lark-skill-maker` | Custom skill creation framework |
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
## Authentication
@@ -250,6 +267,10 @@ We recommend using the Lark/Feishu bot integrated with this tool as a private co
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## Contributing
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).

View File

@@ -2,17 +2,18 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-blue.svg)](https://go.dev/)
[![npm version](https://img.shields.io/npm/v/@larksuite/cli.svg)](https://www.npmjs.com/package/@larksuite/cli)
[中文版](./README.zh.md) | [English](./README.md)
飞书/Lark 开放平台命令行工具 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 19 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 11 大业务域、200+ 精选命令、 19 个 AI Agent [Skills](./skills/)
- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -21,19 +22,20 @@
## 功能
| 类别 | 能力 |
| ------------- | --------------------------------------------------------------------------- |
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📊 多维表格 | 创建和管理多维表格、字段、记录、视图、仪表盘,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 类别 | 能力 |
| ------------- |--------------------------------------------|
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
## 安装与快速开始
@@ -46,31 +48,32 @@
### 快速开始(人类用户)
> **Tip:** 如果你拥有 AI Agent,可以直接把本 README 丢给它,让 AI 帮你完成安装和配置 — 跳转到[快速开始AI Agent](#快速开始ai-agent)查看
> **Note for AI assistants:** 如果你 AI Agent 且正在协助用户执行安装,请直接跳转到[快速开始AI Agent](#快速开始ai-agent)执行,那里包含你需要完成的所有步骤
#### 安装 CLI
#### 安装
**从 npm 安装(推荐):**
以下两种方式**任选其一**
**方式一 — 从 npm 安装(推荐):**
```bash
# 安装 CLI
npm install -g @larksuite/cli
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
```
**从源码安装:**
**方式二 — 从源码安装:**
需要 Go `v1.23`+ 和 Python 3。
```bash
git clone https://github.com/larksuite/cli.git
cd cli
make install
```
#### 安装 AI Agent Skills
[Skills](./skills/) 是结构化的指令文档,使 AI Agent 能够使用本 CLI
```bash
# 安装所有 skills 到当前目录
npx skills add larksuite/cli -y
# 安装所有 skills 到全局
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
```
@@ -91,49 +94,63 @@ lark-cli calendar +agenda
> 以下步骤面向 AI Agent部分步骤需要用户在浏览器中配合完成。
**第 1 步 — 安装**
```bash
# 1. 安装 CLI
# 安装 CLI
npm install -g @larksuite/cli
# 2. 安装 Skills使 AI Agent 能够使用本 CLI
npx skills add larksuite/cli --all -y
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
```
# 3. 配置应用凭证
# 重要:在后台运行此命令,命令会输出一个授权链接,提取该链接并发送给用户,用户在浏览器中完成配置后命令会自动退出。
**第 2 步 — 配置应用凭证**
> 在后台运行此命令,命令会输出一个授权链接,提取该链接并发送给用户,用户在浏览器中完成配置后命令会自动退出。
```bash
lark-cli config init --new
```
# 4. 登录
# 同上,后台运行,提取授权链接发给用户
**第 3 步 — 登录**
> 同上,后台运行,提取授权链接发给用户。
```bash
lark-cli auth login --recommend
```
# 5. 验证
**第 4 步 — 验证**
```bash
lark-cli auth status
```
## Agent Skills
| Skill | 说明 |
| --------------------------------- | ----------------------------------------------------------------------------- |
| Skill | 说明 |
| --------------------------------- |-------------------------------------------|
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
| `lark-wiki` | 知识空间、节点、文档 |
| `lark-event` | 实时事件订阅WebSocket支持正则路由与 Agent 友好格式 |
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
| `lark-openapi-explorer` | 从官方文档探索底层 API |
| `lark-skill-maker` | 自定义 skill 创建框架 |
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
| `lark-wiki` | 知识空间、节点、文档 |
| `lark-event` | 实时事件订阅WebSocket支持正则路由与 Agent 友好格式 |
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
| `lark-openapi-explorer` | 从官方文档探索底层 API |
| `lark-skill-maker` | 自定义 skill 创建框架 |
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
## 认证
@@ -251,6 +268,10 @@ lark-cli schema im.messages.delete
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## 贡献
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。

View File

@@ -40,6 +40,7 @@ type APIOptions struct {
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
}
@@ -96,6 +97,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
@@ -155,6 +157,9 @@ func apiRun(opts *APIOptions) error {
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}
request, err := buildAPIRequest(opts)
if err != nil {
@@ -184,7 +189,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}
@@ -195,25 +200,31 @@ func apiRun(opts *APIOptions) error {
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
})
// MarkRaw tells root error handler that the API response was already written
// to stdout, so it should skip the stderr error envelope. Only apply when
// HandleResponse actually wrote output (i.e. returned a business/API error
// after printing JSON to stdout). Non-JSON HTTP errors (e.g. 404 text/plain)
// produce no stdout output and need the envelope.
if err != nil && client.IsJSONContentType(resp.Header.Get("Content-Type")) {
// MarkRaw tells root error handler to skip enrichPermissionError,
// preserving the original API error detail (log_id, troubleshooter, etc.).
if err != nil {
return output.MarkRaw(err)
}
return err
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
return output.MarkRaw(err)
}
return nil
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)

View File

@@ -446,10 +446,9 @@ func TestApiCmd_APIError_IsRaw(t *testing.T) {
t.Error("expected API error from api command to be marked Raw")
}
// stderr should NOT contain an error envelope (identity line is OK)
if strings.Contains(stderr.String(), `"ok"`) {
t.Error("expected no JSON error envelope on stderr for Raw API error")
}
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
_ = stderr
}
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
@@ -537,6 +536,179 @@ func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
}
}
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--jq", ".data"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.JqExpr != ".data" {
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
}
}
func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "-q", ".data"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.JqExpr != ".data" {
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
}
}
func TestApiCmd_JqAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --jq + --output conflict")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
}
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-jq", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/jq",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice"},
map[string]interface{}{"name": "Bob"},
},
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
t.Errorf("expected jq-filtered names, got: %s", out)
}
// Should NOT contain the full envelope structure
if strings.Contains(out, `"code"`) {
t.Errorf("expected jq to filter out envelope, got: %s", out)
}
}
func TestApiCmd_JqAndFormatConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --jq + --format ndjson conflict")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
}
func TestApiCmd_JqInvalidExpression(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for invalid jq expression")
}
if !strings.Contains(err.Error(), "invalid jq expression") {
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
}
}
func TestApiCmd_PageAll_WithJq(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-pjq", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "u1"}, map[string]interface{}{"id": "u2"}},
"has_more": false,
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "u1") || !strings.Contains(out, "u2") {
t.Errorf("expected jq-filtered ids, got: %s", out)
}
if strings.Contains(out, `"code"`) {
t.Errorf("expected jq to filter out envelope, got: %s", out)
}
}
func TestApiCmd_MethodUppercase(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,

View File

@@ -90,6 +90,7 @@ func completeDomain(toComplete string) []string {
return completions
}
// authLoginRun executes the login command logic.
func authLoginRun(opts *LoginOptions) error {
f := opts.Factory
@@ -225,26 +226,34 @@ func authLoginRun(opts *LoginOptions) error {
// --no-wait: return immediately with device code and URL
if opts.NoWait {
b, _ := json.Marshal(map[string]interface{}{
data := map[string]interface{}{
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
})
fmt.Fprintln(f.IOStreams.Out, string(b))
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
}
return nil
}
// Step 2: Show user code and verification URL
if opts.JSON {
b, _ := json.Marshal(map[string]interface{}{
data := map[string]interface{}{
"event": "device_authorization",
"verification_uri": authResp.VerificationUri,
"verification_uri_complete": authResp.VerificationUriComplete,
"user_code": authResp.UserCode,
"expires_in": authResp.ExpiresIn,
})
fmt.Fprintln(f.IOStreams.Out, string(b))
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
}
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)

View File

@@ -14,9 +14,11 @@ import (
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/update"
)
// DoctorOptions holds inputs for the doctor command.
@@ -60,6 +62,10 @@ func fail(name, msg, hint string) checkResult {
return checkResult{Name: name, Status: "fail", Message: msg, Hint: hint}
}
func warn(name, msg, hint string) checkResult {
return checkResult{Name: name, Status: "warn", Message: msg, Hint: hint}
}
func skip(name, msg string) checkResult {
return checkResult{Name: name, Status: "skip", Message: msg}
}
@@ -68,6 +74,12 @@ func doctorRun(opts *DoctorOptions) error {
f := opts.Factory
var checks []checkResult
// ── 0. CLI version & update check ──
checks = append(checks, pass("cli_version", build.Version))
if !opts.Offline {
checks = append(checks, checkCLIUpdate()...)
}
// ── 1. Config file ──
_, err := core.LoadMultiAppConfig()
if err != nil {
@@ -214,6 +226,23 @@ func mustHTTPClient(f *cmdutil.Factory) *http.Client {
return c
}
// checkCLIUpdate actively queries the npm registry for the latest version.
// Unlike the root-level async check, this does a synchronous fetch with timeout
// and works regardless of build version (dev builds included).
func checkCLIUpdate() []checkResult {
latest, err := update.FetchLatest()
if err != nil {
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
}
current := build.Version
if update.IsNewer(latest, current) {
return []checkResult{warn("cli_update",
fmt.Sprintf("%s → %s available", current, latest),
"run: npm update -g @larksuite/cli")}
}
return []checkResult{pass("cli_update", latest+" (up to date)")}
}
func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
allOK := true
for _, c := range checks {

View File

@@ -4,11 +4,13 @@
package cmd
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"strconv"
"github.com/larksuite/cli/cmd/api"
@@ -24,6 +26,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/update"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -58,6 +61,8 @@ FLAGS:
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
-o, --output <path> output file path for binary responses
--jq <expr> jq expression to filter JSON output
-q <expr> shorthand for --jq
--dry-run print request without executing
AI AGENT SKILLS:
@@ -65,7 +70,7 @@ AI AGENT SKILLS:
teach the agent Lark API patterns, best practices, and workflows.
Install all skills:
npx skills add larksuite/cli --all -y
npx skills add larksuite/cli -g -y
Or pick specific domains:
npx skills add larksuite/cli -s lark-calendar -y
@@ -105,12 +110,68 @@ func Execute() int {
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
// --- Update check (non-blocking) ---
if !isCompletionCommand(os.Args) {
setupUpdateNotice()
}
if err := rootCmd.Execute(); err != nil {
return handleRootError(f, err)
}
return 0
}
// setupUpdateNotice starts an async update check and wires the output decorator.
func setupUpdateNotice() {
// Sync: check cache immediately (no network, fast).
if info := update.CheckCached(build.Version); info != nil {
update.SetPending(info)
}
// Async: refresh cache for this run (and future runs).
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.
if update.GetPending() == nil {
if info := update.CheckCached(build.Version); info != nil {
update.SetPending(info)
}
}
}()
// Wire the output decorator so JSON envelopes include "_notice".
output.PendingNotice = func() map[string]interface{} {
info := update.GetPending()
if info == nil {
return nil
}
return map[string]interface{}{
"update": map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
},
}
}
}
// isCompletionCommand returns true if args indicate a shell completion request.
// Update notifications must be suppressed for these to avoid corrupting
// machine-parseable completion output.
func isCompletionCommand(args []string) bool {
for _, arg := range args {
if arg == "completion" || arg == "__complete" {
return true
}
}
return false
}
// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
func handleRootError(f *cmdutil.Factory, err error) int {
@@ -126,12 +187,11 @@ func handleRootError(f *cmdutil.Factory, err error) int {
// All other structured errors normalize to ExitError.
if exitErr := asExitError(err); exitErr != nil {
if exitErr.Raw {
// Raw errors (e.g. from `api` command) already printed the full API
// response to stdout; skip enrichment and duplicate stderr envelope.
return exitErr.Code
if !exitErr.Raw {
// Raw errors (e.g. from `api` command) preserve the original API
// error detail; skip enrichment which would clear it.
enrichPermissionError(f, exitErr)
}
enrichPermissionError(f, exitErr)
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
return exitErr.Code
}
@@ -184,12 +244,18 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
}
env := map[string]interface{}{"ok": false, "error": errData}
b, err := json.MarshalIndent(env, "", " ")
buffer := &bytes.Buffer{}
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
err := encoder.Encode(env)
if err != nil {
fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`)
return
}
fmt.Fprintln(w, string(b))
fmt.Fprint(w, buffer.String())
}
// installTipsHelpFunc wraps the default help function to append a TIPS section

279
cmd/root_e2e_test.go Normal file
View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
// buildTestRootCmd creates a root command with api, service, and shortcut
// subcommands wired to a test factory, simulating the real CLI command tree.
func buildTestRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(api.NewCmdApi(f, nil))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
return rootCmd
}
// executeE2E runs a command through the full command tree and handleRootError,
// returning exit code — matching real CLI behavior.
func executeE2E(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
t.Helper()
rootCmd.SetArgs(args)
if err := rootCmd.Execute(); err != nil {
return handleRootError(f, err)
}
return 0
}
// registerTokenStub registers a tenant_access_token stub so bot auth succeeds.
func registerTokenStub(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-e2e-token", "expire": 7200,
},
})
}
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
t.Helper()
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var env output.ErrorEnvelope
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
}
return env
}
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
// expected ErrorEnvelope exactly via reflect.DeepEqual.
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
t.Helper()
if code != wantCode {
t.Errorf("exit code: got %d, want %d", code, wantCode)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
got := parseEnvelope(t, stderr)
if !reflect.DeepEqual(got, want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
wantJSON, _ := json.MarshalIndent(want, "", " ")
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
}
}
// --- api command ---
func TestE2E_Api_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerTokenStub(reg)
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/messages",
Body: map[string]interface{}{
"code": 230002,
"msg": "Bot/User can NOT be out of the chat.",
"error": map[string]interface{}{
"log_id": "test-log-id-001",
},
},
})
rootCmd := buildTestRootCmd(t, f)
code := executeE2E(t, f, rootCmd, []string{
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
"--params", `{"receive_id_type":"chat_id"}`,
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
})
// api uses MarkRaw: detail preserved, no enrichment
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 230002,
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
Detail: map[string]interface{}{
"log_id": "test-log-id-001",
},
},
})
}
func TestE2E_Api_PermissionError_NotEnriched(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerTokenStub(reg)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/perm",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled for this app",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
"log_id": "test-log-id-perm",
},
},
})
rootCmd := buildTestRootCmd(t, f)
code := executeE2E(t, f, rootCmd, []string{
"api", "--as", "bot", "GET", "/open-apis/test/perm",
})
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "permission",
Code: 99991672,
Message: "Permission denied [99991672]",
Hint: "check app permissions or re-authorize: lark-cli auth login",
Detail: map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
"log_id": "test-log-id-perm",
},
},
})
}
// --- service command ---
func TestE2E_Service_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerTokenStub(reg)
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_fake",
Body: map[string]interface{}{
"code": 99992356,
"msg": "id not exist",
"error": map[string]interface{}{
"log_id": "test-log-id-svc",
},
},
})
rootCmd := buildTestRootCmd(t, f)
code := executeE2E(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
})
// service: no MarkRaw, non-permission error — detail preserved
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 99992356,
Message: "API error: [99992356] id not exist",
Detail: map[string]interface{}{
"log_id": "test-log-id-svc",
},
},
})
}
func TestE2E_Service_PermissionError_Enriched(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerTokenStub(reg)
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_test",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "im:chat:readonly"},
},
},
},
})
rootCmd := buildTestRootCmd(t, f)
code := executeE2E(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
})
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "permission",
Code: 99991672,
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
Hint: "enable the scope in developer console (see console_url)",
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
},
})
}
// --- shortcut command ---
func TestE2E_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerTokenStub(reg)
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/messages",
Status: 400,
Body: map[string]interface{}{
"code": 230002,
"msg": "Bot/User can NOT be out of the chat.",
},
})
rootCmd := buildTestRootCmd(t, f)
code := executeE2E(t, f, rootCmd, []string{
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
})
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 230002,
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
},
})
}

View File

@@ -65,7 +65,7 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
}
}
func TestHandleRootError_RawError_SkipsEnrichmentAndEnvelope(t *testing.T) {
func TestHandleRootError_RawError_SkipsEnrichmentButWritesEnvelope(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
@@ -82,9 +82,9 @@ func TestHandleRootError_RawError_SkipsEnrichmentAndEnvelope(t *testing.T) {
if code != output.ExitAPI {
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
}
// stderr should be empty — no envelope written
if stderr.Len() != 0 {
t.Errorf("expected empty stderr for Raw error, got: %s", stderr.String())
// stderr should contain the error envelope
if stderr.Len() == 0 {
t.Error("expected non-empty stderr for Raw error — WriteErrorEnvelope should always run")
}
// The message should NOT have been enriched by enrichPermissionError
// (ErrAPI sets "Permission denied [code]" but enrichment would replace it with "App scope not enabled: ...")

View File

@@ -109,6 +109,7 @@ type ServiceMethodOptions struct {
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
}
@@ -157,6 +158,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
@@ -185,6 +187,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}
config, err := f.ResolveConfig(opts.As)
if err != nil {
@@ -223,7 +228,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
}
@@ -234,6 +239,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
CheckError: checkErr,
@@ -400,7 +406,12 @@ func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) e
}
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)

View File

@@ -474,6 +474,173 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
}
}
// ── jq flag ──
func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
cmd.SetArgs([]string{"--jq", ".data"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if captured == nil {
t.Fatal("runF was not called")
}
if captured.JqExpr != ".data" {
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
}
}
func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
cmd.SetArgs([]string{"-q", ".data"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if captured.JqExpr != ".data" {
t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr)
}
}
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --jq + --output conflict")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
}
func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
})
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice"},
map[string]interface{}{"name": "Bob"},
},
},
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
t.Errorf("expected jq-filtered names, got: %s", out)
}
if strings.Contains(out, `"code"`) {
t.Errorf("expected jq to filter out envelope, got: %s", out)
}
}
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --jq + --format ndjson conflict")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
}
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for invalid jq expression")
}
if !strings.Contains(err.Error(), "invalid jq expression") {
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
}
}
func TestServiceMethod_PageAll_WithJq(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu,
})
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "s1"}, map[string]interface{}{"id": "s2"}},
"has_more": false,
},
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "s1") || !strings.Contains(out, "s2") {
t.Errorf("expected jq-filtered ids, got: %s", out)
}
if strings.Contains(out, `"code"`) {
t.Errorf("expected jq to filter out envelope, got: %s", out)
}
}
// ── scopeAwareChecker ──
func TestScopeAwareChecker_Success(t *testing.T) {

2
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
@@ -37,6 +38,7 @@ require (
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect

4
go.sum
View File

@@ -61,6 +61,10 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=

View File

@@ -39,8 +39,8 @@ func MaskToken(token string) string {
// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair.
func GetStoredToken(appId, userOpenId string) *StoredUAToken {
jsonStr := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
if jsonStr == "" {
jsonStr, err := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
if err != nil || jsonStr == "" {
return nil
}
var token StoredUAToken

View File

@@ -4,6 +4,7 @@
package client
import (
"context"
"fmt"
"io"
@@ -16,6 +17,22 @@ type PaginationOptions struct {
PageDelay int // ms, default 200
}
// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter.
// If checkErr detects an error, the raw result is printed as JSON before returning the error.
func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest,
jqExpr string, out io.Writer, pagOpts PaginationOptions,
checkErr func(interface{}) error) error {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.ErrNetwork("API call failed: %v", err)
}
if apiErr := checkErr(result); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.JqFilter(out, result, jqExpr)
}
func mergePagedResults(w io.Writer, results []interface{}) interface{} {
if len(results) == 0 {
return map[string]interface{}{}

View File

@@ -26,6 +26,7 @@ import (
type ResponseOptions struct {
OutputPath string // --output flag; "" = auto-detect
Format output.Format // output format for JSON responses
JqExpr string // if set, apply jq filter instead of Format
Out io.Writer // stdout
ErrOut io.Writer // stderr
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
@@ -62,11 +63,17 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
if opts.OutputPath != "" {
return saveAndPrint(resp, opts.OutputPath, opts.Out)
}
if opts.JqExpr != "" {
return output.JqFilter(opts.Out, result, opts.JqExpr)
}
output.FormatValue(opts.Out, result, opts.Format)
return nil
}
// Non-JSON (binary) responses.
if opts.JqExpr != "" {
return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct)
}
if opts.OutputPath != "" {
return saveAndPrint(resp, opts.OutputPath, opts.Out)
}

View File

@@ -319,6 +319,23 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
}
}
func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) {
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
JqExpr: ".data",
Out: &out,
ErrOut: &errOut,
})
if err == nil {
t.Fatal("expected error when --jq is used with non-JSON response")
}
if !strings.Contains(err.Error(), "--jq requires a JSON response") {
t.Errorf("expected '--jq requires a JSON response' error, got: %v", err)
}
}
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})

View File

@@ -5,11 +5,13 @@ package core
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
@@ -113,6 +115,12 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
app := raw.Apps[0]
secret, err := ResolveSecretInput(app.AppSecret, kc)
if err != nil {
// If the error comes from the keychain, it will already be wrapped as an ExitError.
// For other errors (e.g. file read errors, unknown sources), wrap them as ConfigError.
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return nil, exitErr
}
return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()}
}
cfg := &CliConfig{

View File

@@ -3,17 +3,12 @@
package keychain
import "fmt"
// defaultKeychain implements KeychainAccess using the real platform keychain.
// defaultKeychain is the default implementation of KeychainAccess
// that uses the package-level functions.
type defaultKeychain struct{}
func (d *defaultKeychain) Get(service, account string) (string, error) {
val := Get(service, account)
if val == "" {
return "", fmt.Errorf("keychain entry not found: %s/%s", service, account)
}
return val, nil
return Get(service, account)
}
func (d *defaultKeychain) Set(service, account, value string) error {

View File

@@ -5,6 +5,15 @@
// macOS uses the system Keychain; Linux uses AES-256-GCM encrypted files; Windows uses DPAPI + registry.
package keychain
import (
"errors"
"fmt"
"github.com/larksuite/cli/internal/output"
)
var errNotInitialized = errors.New("keychain not initialized")
const (
// LarkCliService is the unified keychain service name for all secrets
// (both AppSecret and UAT). Entries are distinguished by account key format:
@@ -13,6 +22,22 @@ const (
LarkCliService = "lark-cli"
)
// wrapError is a helper to wrap underlying errors into output.ExitError.
// It formats the error message and provides a hint for troubleshooting keychain access issues.
func wrapError(op string, err error) error {
if err == nil {
return nil
}
msg := fmt.Sprintf("keychain %s failed: %v", op, err)
hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain."
if errors.Is(err, errNotInitialized) {
hint = "The keychain master key may have been cleaned up or deleted. Please reconfigure the CLI by running `lark-cli config init`."
}
return output.ErrWithHint(output.ExitAPI, "config", msg, hint)
}
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
// Used by AppSecret operations (ForStorage, ResolveSecretInput, RemoveSecretStore).
// UAT operations in token_store.go use the package-level Get/Set/Remove directly.
@@ -24,16 +49,17 @@ type KeychainAccess interface {
// Get retrieves a value from the keychain.
// Returns empty string if the entry does not exist.
func Get(service, account string) string {
return platformGet(service, account)
func Get(service, account string) (string, error) {
val, err := platformGet(service, account)
return val, wrapError("Get", err)
}
// Set stores a value in the keychain, overwriting any existing entry.
func Set(service, account, data string) error {
return platformSet(service, account, data)
return wrapError("Set", platformSet(service, account, data))
}
// Remove deletes an entry from the keychain. No error if not found.
func Remove(service, account string) error {
return platformRemove(service, account)
return wrapError("Remove", platformRemove(service, account))
}

View File

@@ -11,6 +11,7 @@ import (
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"os"
"path/filepath"
"regexp"
@@ -36,11 +37,14 @@ func StorageDir(service string) string {
var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
// safeFileName sanitizes an account name to be used as a safe file name.
func safeFileName(account string) string {
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
}
func getMasterKey(service string) ([]byte, error) {
// getMasterKey retrieves the master key from the system keychain.
// If allowCreate is true, it generates and stores a new master key if one doesn't exist.
func getMasterKey(service string, allowCreate bool) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
defer cancel()
@@ -59,28 +63,48 @@ func getMasterKey(service string) ([]byte, error) {
resCh <- result{key: key, err: nil}
return
}
// Key is found but invalid or corrupted
resCh <- result{key: nil, err: errors.New("keychain is corrupted")}
return
} else if !errors.Is(err, keyring.ErrNotFound) {
// Not ErrNotFound, which means access was denied or blocked by the system
resCh <- result{key: nil, err: errors.New("keychain access blocked")}
return
}
// Generate new master key if not found or invalid
// If ErrNotFound, check if we are allowed to create a new key
if !allowCreate {
// Creation not allowed (e.g., during Get operation), return error
resCh <- result{key: nil, err: errNotInitialized}
return
}
// It's the first time and creation is allowed (Set operation), generate a new key
key := make([]byte, masterKeyBytes)
if _, randErr := rand.Read(key); randErr != nil {
resCh <- result{key: nil, err: randErr}
return
}
encodedKey = base64.StdEncoding.EncodeToString(key)
setErr := keyring.Set(service, "master.key", encodedKey)
resCh <- result{key: key, err: setErr}
encodedKeyStr := base64.StdEncoding.EncodeToString(key)
setErr := keyring.Set(service, "master.key", encodedKeyStr)
if setErr != nil {
resCh <- result{key: nil, err: setErr}
return
}
resCh <- result{key: key, err: nil}
}()
select {
case res := <-resCh:
return res.key, res.err
case <-ctx.Done():
return nil, ctx.Err()
// Timeout is usually caused by ignored/blocked permission prompts
return nil, errors.New("keychain access blocked")
}
}
// encryptData encrypts data using AES-GCM.
func encryptData(plaintext string, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
@@ -103,6 +127,7 @@ func encryptData(plaintext string, key []byte) ([]byte, error) {
return result, nil
}
// decryptData decrypts data using AES-GCM.
func decryptData(data []byte, key []byte) (string, error) {
if len(data) < ivBytes+tagBytes {
return "", os.ErrInvalid
@@ -125,24 +150,30 @@ func decryptData(data []byte, key []byte) (string, error) {
return string(plaintext), nil
}
func platformGet(service, account string) string {
key, err := getMasterKey(service)
if err != nil {
return ""
// platformGet retrieves a value from the macOS keychain.
func platformGet(service, account string) (string, error) {
path := filepath.Join(StorageDir(service), safeFileName(account))
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
return ""
return "", err
}
key, err := getMasterKey(service, false)
if err != nil {
return "", err
}
plaintext, err := decryptData(data, key)
if err != nil {
return ""
return "", err
}
return plaintext
return plaintext, nil
}
// platformSet stores a value in the macOS keychain.
func platformSet(service, account, data string) error {
key, err := getMasterKey(service)
key, err := getMasterKey(service, true)
if err != nil {
return err
}
@@ -170,6 +201,7 @@ func platformSet(service, account, data string) error {
return nil
}
// platformRemove deletes a value from the macOS keychain.
func platformRemove(service, account string) error {
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil && !os.IsNotExist(err) {

View File

@@ -9,6 +9,7 @@ import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"os"
"path/filepath"
@@ -21,8 +22,7 @@ const masterKeyBytes = 32
const ivBytes = 12
const tagBytes = 16
// StorageDir returns the storage directory for a given service name.
// Each service gets its own directory for physical isolation.
// StorageDir returns the directory where encrypted files are stored.
func StorageDir(service string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
@@ -36,11 +36,14 @@ func StorageDir(service string) string {
var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
// safeFileName sanitizes an account name to be used as a safe file name.
func safeFileName(account string) string {
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
}
func getMasterKey(service string) ([]byte, error) {
// getMasterKey retrieves the master key from the file system.
// If allowCreate is true, it generates and stores a new master key if one doesn't exist.
func getMasterKey(service string, allowCreate bool) ([]byte, error) {
dir := StorageDir(service)
keyPath := filepath.Join(dir, "master.key")
@@ -48,6 +51,18 @@ func getMasterKey(service string) ([]byte, error) {
if err == nil && len(key) == masterKeyBytes {
return key, nil
}
if err == nil && len(key) != masterKeyBytes {
// Key file exists but is corrupted
return nil, errors.New("keychain is corrupted")
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
// Real I/O error (permission denied, etc.) - propagate it
return nil, err
}
if !allowCreate {
return nil, errNotInitialized
}
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
@@ -78,6 +93,7 @@ func getMasterKey(service string) ([]byte, error) {
return key, nil
}
// encryptData encrypts data using AES-GCM.
func encryptData(plaintext string, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
@@ -100,6 +116,7 @@ func encryptData(plaintext string, key []byte) ([]byte, error) {
return result, nil
}
// decryptData decrypts data using AES-GCM.
func decryptData(data []byte, key []byte) (string, error) {
if len(data) < ivBytes+tagBytes {
return "", os.ErrInvalid
@@ -122,24 +139,30 @@ func decryptData(data []byte, key []byte) (string, error) {
return string(plaintext), nil
}
func platformGet(service, account string) string {
key, err := getMasterKey(service)
if err != nil {
return ""
// platformGet retrieves a value from the file system.
func platformGet(service, account string) (string, error) {
path := filepath.Join(StorageDir(service), safeFileName(account))
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
return ""
return "", err
}
key, err := getMasterKey(service, false)
if err != nil {
return "", err
}
plaintext, err := decryptData(data, key)
if err != nil {
return ""
return "", err
}
return plaintext
return plaintext, nil
}
// platformSet stores a value in the file system.
func platformSet(service, account, data string) error {
key, err := getMasterKey(service)
key, err := getMasterKey(service, true)
if err != nil {
return err
}
@@ -167,6 +190,7 @@ func platformSet(service, account, data string) error {
return nil
}
// platformRemove deletes a value from the file system.
func platformRemove(service, account string) error {
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil && !os.IsNotExist(err) {

View File

@@ -22,12 +22,14 @@ import (
const regRootPath = `Software\LarkCli\keychain`
// registryPathForService returns the registry path for a given service.
func registryPathForService(service string) string {
return regRootPath + `\` + safeRegistryComponent(service)
}
var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
// safeRegistryComponent sanitizes a string to be used as a registry key component.
func safeRegistryComponent(s string) string {
// Registry key path uses '\\' separators; avoid accidental nesting and odd chars.
s = strings.ReplaceAll(s, "\\", "_")
@@ -39,6 +41,7 @@ func valueNameForAccount(account string) string {
return base64.RawURLEncoding.EncodeToString([]byte(account))
}
// dpapiEntropy generates entropy for DPAPI encryption based on the service and account names.
func dpapiEntropy(service, account string) *windows.DataBlob {
// Bind ciphertext to (service, account) to reduce swap/replay risks.
// Note: empty entropy is allowed, but we intentionally use deterministic entropy.
@@ -49,6 +52,7 @@ func dpapiEntropy(service, account string) *windows.DataBlob {
return &windows.DataBlob{Size: uint32(len(data)), Data: &data[0]}
}
// dpapiProtect encrypts data using Windows DPAPI.
func dpapiProtect(plaintext []byte, entropy *windows.DataBlob) ([]byte, error) {
var in windows.DataBlob
if len(plaintext) > 0 {
@@ -70,6 +74,7 @@ func dpapiProtect(plaintext []byte, entropy *windows.DataBlob) ([]byte, error) {
return res, nil
}
// dpapiUnprotect decrypts data using Windows DPAPI.
func dpapiUnprotect(ciphertext []byte, entropy *windows.DataBlob) ([]byte, error) {
var in windows.DataBlob
if len(ciphertext) > 0 {
@@ -91,6 +96,7 @@ func dpapiUnprotect(ciphertext []byte, entropy *windows.DataBlob) ([]byte, error
return res, nil
}
// freeDataBlob frees the memory allocated for a DataBlob.
func freeDataBlob(b *windows.DataBlob) {
if b == nil || b.Data == nil {
return
@@ -101,11 +107,16 @@ func freeDataBlob(b *windows.DataBlob) {
b.Size = 0
}
func platformGet(service, account string) string {
v, _ := registryGet(service, account)
return v
// platformGet retrieves a value from the Windows registry.
func platformGet(service, account string) (string, error) {
v, ok := registryGet(service, account)
if !ok {
return "", nil
}
return v, nil
}
// platformSet stores a value in the Windows registry.
func platformSet(service, account, data string) error {
entropy := dpapiEntropy(service, account)
protected, err := dpapiProtect([]byte(data), entropy)
@@ -115,10 +126,12 @@ func platformSet(service, account, data string) error {
return registrySet(service, account, protected)
}
// platformRemove deletes a value from the Windows registry.
func platformRemove(service, account string) error {
return registryRemove(service, account)
}
// registryGet retrieves a string value from the registry under the given service and account.
func registryGet(service, account string) (string, bool) {
keyPath := registryPathForService(service)
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE)
@@ -143,6 +156,7 @@ func registryGet(service, account string) (string, bool) {
return string(plain), true
}
// registrySet stores a string value in the registry under the given service and account.
func registrySet(service, account string, protected []byte) error {
keyPath := registryPathForService(service)
k, _, err := registry.CreateKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)
@@ -158,6 +172,7 @@ func registrySet(service, account string, protected []byte) error {
return nil
}
// registryRemove deletes a value from the registry under the given service and account.
func registryRemove(service, account string) error {
keyPath := registryPathForService(service)
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)

View File

@@ -5,18 +5,20 @@ package output
// Envelope is the standard success response wrapper.
type Envelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Data interface{} `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Data interface{} `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
Notice map[string]interface{} `json:"_notice,omitempty"`
}
// ErrorEnvelope is the standard error response wrapper.
type ErrorEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error *ErrDetail `json:"error"`
Meta *Meta `json:"meta,omitempty"`
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error *ErrDetail `json:"error"`
Meta *Meta `json:"meta,omitempty"`
Notice map[string]interface{} `json:"_notice,omitempty"`
}
// ErrDetail describes a structured error.
@@ -34,3 +36,17 @@ type Meta struct {
Count int `json:"count,omitempty"`
Rollback string `json:"rollback,omitempty"`
}
// PendingNotice, if set, returns system-level notices to inject as the
// "_notice" field in JSON output envelopes. Set by cmd/root.go.
// Returns nil when there is nothing to report.
var PendingNotice func() map[string]interface{}
// GetNotice returns the current pending notice for struct-based callers.
// Returns nil when there is nothing to report.
func GetNotice() map[string]interface{} {
if PendingNotice == nil {
return nil
}
return PendingNotice()
}

View File

@@ -40,10 +40,11 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
if err.Detail == nil {
return
}
env := ErrorEnvelope{
env := &ErrorEnvelope{
OK: false,
Identity: identity,
Error: err.Detail,
Notice: GetNotice(),
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)

View File

@@ -4,6 +4,8 @@
package output
import (
"bytes"
"encoding/json"
"fmt"
"testing"
)
@@ -37,3 +39,112 @@ func TestMarkRaw_Nil(t *testing.T) {
t.Error("expected MarkRaw(nil) to return nil")
}
}
func TestWriteErrorEnvelope_WithNotice(t *testing.T) {
// Set up PendingNotice
origNotice := PendingNotice
PendingNotice = func() map[string]interface{} {
return map[string]interface{}{
"update": map[string]interface{}{
"current": "1.0.0",
"latest": "2.0.0",
},
}
}
defer func() { PendingNotice = origNotice }()
exitErr := &ExitError{
Code: 1,
Detail: &ErrDetail{Type: "api_error", Message: "something failed"},
}
var buf bytes.Buffer
WriteErrorEnvelope(&buf, exitErr, "user")
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
// Verify _notice is present
notice, ok := env["_notice"].(map[string]interface{})
if !ok {
t.Fatal("expected _notice field in output")
}
update, ok := notice["update"].(map[string]interface{})
if !ok {
t.Fatal("expected _notice.update field")
}
if update["latest"] != "2.0.0" {
t.Errorf("expected latest=2.0.0, got %v", update["latest"])
}
// Verify standard fields
if env["ok"] != false {
t.Error("expected ok=false")
}
if env["identity"] != "user" {
t.Errorf("expected identity=user, got %v", env["identity"])
}
}
func TestWriteErrorEnvelope_WithoutNotice(t *testing.T) {
// Ensure PendingNotice is nil
origNotice := PendingNotice
PendingNotice = nil
defer func() { PendingNotice = origNotice }()
exitErr := &ExitError{
Code: 1,
Detail: &ErrDetail{Type: "api_error", Message: "something failed"},
}
var buf bytes.Buffer
WriteErrorEnvelope(&buf, exitErr, "bot")
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
if _, ok := env["_notice"]; ok {
t.Error("expected no _notice field when PendingNotice is nil")
}
}
func TestWriteErrorEnvelope_NilDetail(t *testing.T) {
exitErr := &ExitError{Code: 1}
var buf bytes.Buffer
WriteErrorEnvelope(&buf, exitErr, "user")
if buf.Len() != 0 {
t.Errorf("expected no output for nil Detail, got: %s", buf.String())
}
}
func TestGetNotice(t *testing.T) {
// Nil PendingNotice → nil
origNotice := PendingNotice
PendingNotice = nil
if got := GetNotice(); got != nil {
t.Errorf("expected nil, got %v", got)
}
// With PendingNotice → returns value
PendingNotice = func() map[string]interface{} {
return map[string]interface{}{"update": "test"}
}
got := GetNotice()
if got == nil || got["update"] != "test" {
t.Errorf("expected {update: test}, got %v", got)
}
// PendingNotice returns nil → nil
PendingNotice = func() map[string]interface{} { return nil }
if got := GetNotice(); got != nil {
t.Errorf("expected nil, got %v", got)
}
PendingNotice = origNotice
}

132
internal/output/jq.go Normal file
View File

@@ -0,0 +1,132 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"encoding/json"
"fmt"
"io"
"math/big"
"github.com/itchyny/gojq"
)
// JqFilter applies a jq expression to data and writes the results to w.
// Scalar values are printed raw (no quotes for strings), matching jq -r behavior.
// Complex values (maps, arrays) are printed as indented JSON.
func JqFilter(w io.Writer, data interface{}, expr string) error {
query, err := gojq.Parse(expr)
if err != nil {
return ErrValidation("invalid jq expression: %s", err)
}
code, err := gojq.Compile(query)
if err != nil {
return ErrValidation("invalid jq expression: %s", err)
}
// Normalize data through toGeneric so typed structs become map[string]any.
normalized := toGeneric(data)
// Convert json.Number values to gojq-compatible types.
normalized = convertNumbers(normalized)
iter := code.Run(normalized)
for {
v, ok := iter.Next()
if !ok {
break
}
if err, isErr := v.(error); isErr {
return Errorf(ExitAPI, "jq_error", "jq error: %s", err)
}
if err := writeJqValue(w, v); err != nil {
return err
}
}
return nil
}
// ValidateJqFlags checks --jq flag compatibility with --output and --format flags,
// and validates the jq expression syntax. Returns nil if jqExpr is empty.
func ValidateJqFlags(jqExpr, outputFlag, format string) error {
if jqExpr == "" {
return nil
}
if outputFlag != "" {
return ErrValidation("--jq and --output are mutually exclusive")
}
if format != "" && format != "json" {
return ErrValidation("--jq and --format %s are mutually exclusive", format)
}
return ValidateJqExpression(jqExpr)
}
// ValidateJqExpression checks whether a jq expression is syntactically valid.
func ValidateJqExpression(expr string) error {
query, err := gojq.Parse(expr)
if err != nil {
return ErrValidation("invalid jq expression: %s", err)
}
_, err = gojq.Compile(query)
if err != nil {
return ErrValidation("invalid jq expression: %s", err)
}
return nil
}
// writeJqValue writes a single jq result value to w.
// Scalars are printed raw; complex values as indented JSON.
func writeJqValue(w io.Writer, v interface{}) error {
switch val := v.(type) {
case nil:
fmt.Fprintln(w, "null")
case bool:
fmt.Fprintln(w, val)
case int:
fmt.Fprintln(w, val)
case float64:
// Use %g to avoid trailing zeros, matching jq behavior.
fmt.Fprintf(w, "%g\n", val)
case *big.Int:
fmt.Fprintln(w, val.String())
case string:
// Raw output for strings (no quotes), matching jq -r.
fmt.Fprintln(w, val)
default:
// Complex value (map, array): indented JSON.
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return Errorf(ExitInternal, "jq_error", "failed to marshal jq result: %s", err)
}
fmt.Fprintln(w, string(b))
}
return nil
}
// convertNumbers recursively converts json.Number values to int or float64
// so that gojq can process them correctly.
func convertNumbers(v interface{}) interface{} {
switch val := v.(type) {
case json.Number:
if i, err := val.Int64(); err == nil {
return int(i)
}
if f, err := val.Float64(); err == nil {
return f
}
// Fallback: return as string (shouldn't happen for valid JSON numbers).
return val.String()
case map[string]interface{}:
for k, elem := range val {
val[k] = convertNumbers(elem)
}
return val
case []interface{}:
for i, elem := range val {
val[i] = convertNumbers(elem)
}
return val
default:
return v
}
}

215
internal/output/jq_test.go Normal file
View File

@@ -0,0 +1,215 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
func TestJqFilter(t *testing.T) {
data := map[string]interface{}{
"ok": true,
"identity": "user",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice", "age": 30},
map[string]interface{}{"name": "Bob", "age": 25},
map[string]interface{}{"name": "Charlie", "age": 35},
},
"total": 3,
},
"meta": map[string]interface{}{
"count": 3,
},
}
tests := []struct {
name string
expr string
want string
wantErr bool
}{
{
name: "identity expression",
expr: ".",
want: `"ok"`,
},
{
name: "field access .ok",
expr: ".ok",
want: "true\n",
},
{
name: "string field raw output",
expr: ".identity",
want: "user\n",
},
{
name: "nested field access",
expr: ".data.total",
want: "3\n",
},
{
name: "meta count",
expr: ".meta.count",
want: "3\n",
},
{
name: "array iteration",
expr: ".data.items[].name",
want: "Alice\nBob\nCharlie\n",
},
{
name: "pipe and select",
expr: `.data.items[] | select(.age > 28) | .name`,
want: "Alice\nCharlie\n",
},
{
name: "length builtin",
expr: ".data.items | length",
want: "3\n",
},
{
name: "keys builtin",
expr: ".data | keys",
want: "[\n \"items\",\n \"total\"\n]\n",
},
{
name: "null for missing field",
expr: ".nonexistent",
want: "null\n",
},
{
name: "complex value output",
expr: ".data.items[0]",
want: "{\n \"age\": 30,\n \"name\": \"Alice\"\n}\n",
},
{
name: "invalid expression",
expr: "invalid[",
wantErr: true,
},
{
name: "multiple outputs",
expr: ".ok, .identity",
want: "true\nuser\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
err := JqFilter(&buf, data, tt.expr)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.name == "identity expression" {
// For identity, just verify it contains the key fields
if !strings.Contains(buf.String(), `"ok"`) {
t.Errorf("identity output missing 'ok' key")
}
return
}
if buf.String() != tt.want {
t.Errorf("got %q, want %q", buf.String(), tt.want)
}
})
}
}
func TestJqFilter_WithStruct(t *testing.T) {
// Test that toGeneric normalizes structs properly
type inner struct {
Name string `json:"name"`
}
data := struct {
OK bool `json:"ok"`
Item *inner `json:"item"`
}{
OK: true,
Item: &inner{Name: "test"},
}
var buf bytes.Buffer
err := JqFilter(&buf, data, ".item.name")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "test" {
t.Errorf("got %q, want %q", got, "test")
}
}
func TestValidateJqFlags(t *testing.T) {
tests := []struct {
name string
jqExpr string
outputFlag string
format string
wantErr string
}{
{name: "empty jq is noop", jqExpr: "", outputFlag: "file.json", format: "csv", wantErr: ""},
{name: "jq only", jqExpr: ".data", outputFlag: "", format: "", wantErr: ""},
{name: "jq with json format", jqExpr: ".data", outputFlag: "", format: "json", wantErr: ""},
{name: "jq and output conflict", jqExpr: ".data", outputFlag: "out.json", format: "", wantErr: "--jq and --output are mutually exclusive"},
{name: "jq and csv conflict", jqExpr: ".data", outputFlag: "", format: "csv", wantErr: "--jq and --format csv are mutually exclusive"},
{name: "jq and ndjson conflict", jqExpr: ".data", outputFlag: "", format: "ndjson", wantErr: "--jq and --format ndjson are mutually exclusive"},
{name: "invalid expression", jqExpr: "invalid[", outputFlag: "", format: "", wantErr: "invalid jq expression"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateJqFlags(tt.jqExpr, tt.outputFlag, tt.format)
if tt.wantErr == "" {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.wantErr)
return
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr)
}
})
}
}
func TestValidateJqExpression(t *testing.T) {
tests := []struct {
expr string
wantErr bool
}{
{".", false},
{".data", false},
{".data.items[].name", false},
{`.data.items[] | select(.name == "Alice")`, false},
{"length", false},
{"keys", false},
{"invalid[", true},
{".foo | invalid_func", true},
}
for _, tt := range tests {
t.Run(tt.expr, func(t *testing.T) {
err := ValidateJqExpression(tt.expr)
if tt.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}

View File

@@ -14,6 +14,7 @@ import (
// PrintJson prints data as formatted JSON to w.
func PrintJson(w io.Writer, data interface{}) {
injectNotice(data)
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
@@ -22,6 +23,31 @@ func PrintJson(w io.Writer, data interface{}) {
fmt.Fprintln(w, string(b))
}
// injectNotice adds a "_notice" field into CLI envelope maps.
// Only modifies map[string]interface{} values that have an "ok" key
// (e.g. doctor, auth, config commands that build map envelopes directly).
//
// Struct-based envelopes (Envelope, ErrorEnvelope) are NOT handled here —
// callers must set the Notice field explicitly via GetNotice().
// See: shortcuts/common/runner.go Out(), output/errors.go WriteErrorEnvelope().
func injectNotice(data interface{}) {
if PendingNotice == nil {
return
}
m, ok := data.(map[string]interface{})
if !ok {
return
}
if _, isEnvelope := m["ok"]; !isEnvelope {
return
}
notice := PendingNotice()
if notice == nil {
return
}
m["_notice"] = notice
}
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
func PrintNdjson(w io.Writer, data interface{}) {
emit := func(item interface{}) {

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"encoding/json"
"testing"
)
func TestPrintJson_InjectNotice_Map(t *testing.T) {
origNotice := PendingNotice
PendingNotice = func() map[string]interface{} {
return map[string]interface{}{"update": "available"}
}
defer func() { PendingNotice = origNotice }()
data := map[string]interface{}{"ok": true, "data": "test"}
var buf bytes.Buffer
PrintJson(&buf, data)
var got map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("failed to parse: %v", err)
}
notice, ok := got["_notice"].(map[string]interface{})
if !ok {
t.Fatal("expected _notice in map-based envelope")
}
if notice["update"] != "available" {
t.Errorf("expected update=available, got %v", notice["update"])
}
}
func TestPrintJson_InjectNotice_SkipsNonEnvelope(t *testing.T) {
origNotice := PendingNotice
PendingNotice = func() map[string]interface{} {
return map[string]interface{}{"update": "available"}
}
defer func() { PendingNotice = origNotice }()
// Map without "ok" key should not get _notice
data := map[string]interface{}{"name": "test"}
var buf bytes.Buffer
PrintJson(&buf, data)
var got map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("failed to parse: %v", err)
}
if _, ok := got["_notice"]; ok {
t.Error("expected no _notice for non-envelope map")
}
}
func TestPrintJson_Struct_PreservesNotice(t *testing.T) {
origNotice := PendingNotice
PendingNotice = nil // no global notice
defer func() { PendingNotice = origNotice }()
// Struct with Notice already set should preserve it
env := &Envelope{
OK: true,
Identity: "user",
Data: "hello",
Notice: map[string]interface{}{"update": "set-by-caller"},
}
var buf bytes.Buffer
PrintJson(&buf, env)
var got map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("failed to parse: %v", err)
}
notice, ok := got["_notice"].(map[string]interface{})
if !ok {
t.Fatal("expected _notice from struct field")
}
if notice["update"] != "set-by-caller" {
t.Errorf("expected update=set-by-caller, got %v", notice["update"])
}
}
func TestPrintJson_NoNotice(t *testing.T) {
origNotice := PendingNotice
PendingNotice = nil
defer func() { PendingNotice = origNotice }()
data := map[string]interface{}{"ok": true, "data": "test"}
var buf bytes.Buffer
PrintJson(&buf, data)
var got map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("failed to parse: %v", err)
}
if _, ok := got["_notice"]; ok {
t.Error("expected no _notice when PendingNotice is nil")
}
}

View File

@@ -1,7 +1,11 @@
{
"approval": {
"en": { "title": "Approval", "description": "Approval instance, and task management" },
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
},
"base": {
"en": { "title": "Base", "description": "Table, field, record, and view management" },
"zh": { "title": "多维表格", "description": "数据表、字段、记录、视图" }
"en": { "title": "Base", "description": "Table, field, record, view, dashboard, workflow, form, role & permission management" },
"zh": { "title": "多维表格", "description": "数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限管理" }
},
"calendar": {
"en": { "title": "Calendar", "description": "Calendar, event, and attendee management" },

255
internal/update/update.go Normal file
View File

@@ -0,0 +1,255 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package update
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
)
const (
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
cacheTTL = 24 * time.Hour
fetchTimeout = 5 * time.Second
stateFile = "update-state.json"
maxBody = 256 << 10 // 256 KB
)
// UpdateInfo holds version update information.
type UpdateInfo struct {
Current string `json:"current"`
Latest string `json:"latest"`
}
// Message returns a concise update notification.
func (u *UpdateInfo) Message() string {
return fmt.Sprintf("lark-cli %s available, current %s", u.Latest, u.Current)
}
// pending stores the latest update info for the current process.
var pending atomic.Pointer[UpdateInfo]
// SetPending stores the update info for consumption by output decorators.
func SetPending(info *UpdateInfo) { pending.Store(info) }
// GetPending returns the pending update info, or nil.
func GetPending() *UpdateInfo { return pending.Load() }
// DefaultClient is the HTTP client used for npm registry requests.
// Override in tests with an httptest server client.
var DefaultClient *http.Client
func httpClient() *http.Client {
if DefaultClient != nil {
return DefaultClient
}
return &http.Client{Timeout: fetchTimeout}
}
// updateState is persisted to disk for caching.
type updateState struct {
LatestVersion string `json:"latest_version"`
CheckedAt int64 `json:"checked_at"`
}
// CheckCached checks the local cache only (no network). Always fast.
func CheckCached(currentVersion string) *UpdateInfo {
if shouldSkip(currentVersion) {
return nil
}
state, _ := loadState()
if state == nil || state.LatestVersion == "" {
return nil
}
if !IsNewer(state.LatestVersion, currentVersion) {
return nil
}
return &UpdateInfo{Current: currentVersion, Latest: state.LatestVersion}
}
// RefreshCache fetches the latest version from npm and updates the local cache.
// No-op if the cache is still fresh (< 24h). Safe to call from a goroutine.
func RefreshCache(currentVersion string) {
if shouldSkip(currentVersion) {
return
}
state, _ := loadState()
if state != nil && time.Since(time.Unix(state.CheckedAt, 0)) < cacheTTL {
return // cache is fresh
}
latest, err := fetchLatestVersion()
if err != nil {
return
}
_ = saveState(&updateState{
LatestVersion: latest,
CheckedAt: time.Now().Unix(),
})
}
func shouldSkip(version string) bool {
if os.Getenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER") != "" {
return true
}
// Suppress in CI environments.
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
if os.Getenv(key) != "" {
return true
}
}
// No version info at all — can't compare.
if version == "DEV" || version == "dev" || version == "" {
return true
}
// Skip local dev builds (e.g. v1.0.0-12-g9b933f1-dirty from git describe).
// Only released versions (clean X.Y.Z) should check for updates.
if !isRelease(version) {
return true
}
return false
}
// isRelease returns true for published versions: clean semver (1.0.0)
// and npm prerelease (1.0.0-beta.1, 1.0.0-rc.1).
// Returns false for git describe dev builds (v1.0.0-12-g9b933f1-dirty).
var gitDescribePattern = regexp.MustCompile(`-\d+-g[0-9a-f]{7,}`)
func isRelease(version string) bool {
v := strings.TrimPrefix(version, "v")
if ParseVersion(v) == nil {
return false
}
return !gitDescribePattern.MatchString(v)
}
// --- state file I/O ---
func statePath() string {
return filepath.Join(core.GetConfigDir(), stateFile)
}
func loadState() (*updateState, error) {
data, err := os.ReadFile(statePath())
if err != nil {
return nil, err
}
var s updateState
if err := json.Unmarshal(data, &s); err != nil {
return nil, err
}
return &s, nil
}
func saveState(s *updateState) error {
dir := core.GetConfigDir()
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
data, err := json.Marshal(s)
if err != nil {
return err
}
return validate.AtomicWrite(statePath(), data, 0644)
}
// FetchLatest queries the npm registry and returns the latest published version.
// This is a synchronous call with timeout, intended for diagnostic commands (doctor).
func FetchLatest() (string, error) {
return fetchLatestVersion()
}
// --- npm registry ---
type npmLatestResponse struct {
Version string `json:"version"`
}
func fetchLatestVersion() (string, error) {
resp, err := httpClient().Get(registryURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("npm registry: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return "", err
}
var result npmLatestResponse
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
if result.Version == "" {
return "", fmt.Errorf("npm registry: empty version")
}
return result.Version, nil
}
// --- semver helpers ---
// IsNewer returns true if version a should be considered an update over b.
//
// When both parse as semver, standard comparison applies.
// When b cannot be parsed (e.g. bare commit hash "9b933f1"), any valid a
// is considered newer — an unparseable local version is assumed outdated.
// When a cannot be parsed, returns false (can't confirm it's newer).
func IsNewer(a, b string) bool {
ap := ParseVersion(a)
bp := ParseVersion(b)
if ap == nil {
return false // can't confirm remote is newer
}
if bp == nil {
return true // local version unparseable → assume outdated
}
for i := 0; i < 3; i++ {
if ap[i] > bp[i] {
return true
}
if ap[i] < bp[i] {
return false
}
}
return false
}
// ParseVersion parses "X.Y.Z" (with optional "v" prefix and pre-release suffix)
// into [major, minor, patch]. Returns nil on invalid input.
func ParseVersion(v string) []int {
v = strings.TrimPrefix(v, "v")
parts := strings.SplitN(v, ".", 3)
if len(parts) != 3 {
return nil
}
nums := make([]int, 3)
for i, p := range parts {
if idx := strings.IndexAny(p, "-+"); idx >= 0 {
p = p[:idx]
}
n, err := strconv.Atoi(p)
if err != nil {
return nil
}
nums[i] = n
}
return nums
}

View File

@@ -0,0 +1,253 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package update
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// roundTripFunc adapts a function to http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
// clearSkipEnv unsets all env vars that shouldSkip checks,
// preventing the host environment (e.g. CI=true) from polluting test results.
func clearSkipEnv(t *testing.T) {
t.Helper()
for _, key := range []string{"LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "CI", "BUILD_NUMBER", "RUN_ID"} {
t.Setenv(key, "")
os.Unsetenv(key)
}
}
func mustParseURL(raw string) *url.URL {
u, err := url.Parse(raw)
if err != nil {
panic(err)
}
return u
}
func TestIsNewer(t *testing.T) {
tests := []struct {
a, b string
want bool
}{
{"1.1.0", "1.0.0", true},
{"1.0.0", "1.0.0", false},
{"1.0.0", "1.1.0", false},
{"2.0.0", "1.9.9", true},
{"1.0.1", "1.0.0", true},
{"v1.1.0", "1.0.0", true},
{"1.1.0", "v1.0.0", true},
{"0.0.1", "0.0.0", true},
{"DEV", "1.0.0", false}, // unparseable remote → false
{"1.0.0", "DEV", true}, // unparseable local → assume outdated
{"1.0.0", "9b933f1", true}, // bare commit hash → assume outdated
{"", "1.0.0", false}, // empty remote → false
{"1.1.0", "v1.0.0-12-g9b933f1-dirty", true}, // git describe: 1.1.0 > 1.0.0
}
for _, tt := range tests {
got := IsNewer(tt.a, tt.b)
if got != tt.want {
t.Errorf("IsNewer(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
}
}
}
func TestParseVersion(t *testing.T) {
tests := []struct {
input string
want []int
}{
{"1.2.3", []int{1, 2, 3}},
{"v1.2.3", []int{1, 2, 3}},
{"0.0.1", []int{0, 0, 1}},
{"1.0.0-beta.1", []int{1, 0, 0}},
{"DEV", nil},
{"", nil},
{"1.2", nil},
}
for _, tt := range tests {
got := ParseVersion(tt.input)
if tt.want == nil {
if got != nil {
t.Errorf("ParseVersion(%q) = %v, want nil", tt.input, got)
}
continue
}
if got == nil || got[0] != tt.want[0] || got[1] != tt.want[1] || got[2] != tt.want[2] {
t.Errorf("ParseVersion(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestShouldSkip(t *testing.T) {
tests := []struct {
name string
version string
env map[string]string
want bool
}{
{"DEV", "DEV", nil, true},
{"dev_lower", "dev", nil, true},
{"empty", "", nil, true},
{"CI", "1.0.0", map[string]string{"CI": "true"}, true},
{"BUILD_NUMBER", "1.0.0", map[string]string{"BUILD_NUMBER": "42"}, true},
{"RUN_ID", "1.0.0", map[string]string{"RUN_ID": "123"}, true},
{"notifier_off", "1.0.0", map[string]string{"LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1"}, true},
{"git_describe", "v1.0.0-12-g9b933f1", nil, true},
{"git_dirty", "v1.0.0-12-g9b933f1-dirty", nil, true},
{"commit_hash", "9b933f1", nil, true},
{"clean_semver", "1.0.0", nil, false},
{"clean_semver_v", "v1.0.0", nil, false},
{"prerelease_beta", "1.0.0-beta.1", nil, false},
{"prerelease_rc", "2.0.0-rc.1", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearSkipEnv(t)
for k, v := range tt.env {
t.Setenv(k, v)
}
got := shouldSkip(tt.version)
if got != tt.want {
t.Errorf("shouldSkip(%q) = %v, want %v", tt.version, got, tt.want)
}
})
}
}
func TestIsRelease(t *testing.T) {
tests := []struct {
version 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
}
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)
}
})
}
}
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)
}
}
func TestCheckCached(t *testing.T) {
clearSkipEnv(t)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
// No cache → nil
info := CheckCached("1.0.0")
if info != nil {
t.Errorf("expected nil with no cache, got %+v", info)
}
// Write cache with newer version
state := &updateState{LatestVersion: "2.0.0", CheckedAt: time.Now().Unix()}
data, _ := json.Marshal(state)
os.WriteFile(filepath.Join(tmp, stateFile), data, 0644)
info = CheckCached("1.0.0")
if info == nil {
t.Fatal("expected update info, got nil")
}
if info.Latest != "2.0.0" || info.Current != "1.0.0" {
t.Errorf("unexpected info: %+v", info)
}
// Same version → nil
info = CheckCached("2.0.0")
if info != nil {
t.Errorf("expected nil when versions match, got %+v", info)
}
}
func TestRefreshCache(t *testing.T) {
clearSkipEnv(t)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
// Set up mock npm registry via DefaultClient
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(npmLatestResponse{Version: "3.0.0"})
}))
defer srv.Close()
// Redirect all requests to the mock server.
DefaultClient = srv.Client()
DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
req.URL = mustParseURL(srv.URL + req.URL.Path)
return http.DefaultTransport.RoundTrip(req)
})
defer func() { DefaultClient = nil }()
RefreshCache("1.0.0")
// Verify cache was written
info := CheckCached("1.0.0")
if info == nil {
t.Fatal("expected update info after refresh, got nil")
}
if info.Latest != "3.0.0" {
t.Errorf("expected latest 3.0.0, got %s", info.Latest)
}
// Second refresh should be no-op (cache is fresh) — won't hit network.
RefreshCache("1.0.0")
}
func TestPendingAtomicAccess(t *testing.T) {
// Initially nil
if got := GetPending(); got != nil {
t.Errorf("expected nil, got %+v", got)
}
info := &UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
SetPending(info)
got := GetPending()
if got == nil || got.Current != "1.0.0" || got.Latest != "2.0.0" {
t.Errorf("unexpected pending: %+v", got)
}
// Clean up for other tests
SetPending(nil)
}

View File

@@ -181,6 +181,25 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
return cloned
}
// DialContextFunc is the signature for DialContext / DialTLSContext.
type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
// WrapDialContextWithIPCheck wraps a DialContext function to validate the
// remote IP after connection, rejecting local/internal addresses (SSRF protection).
func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialConn(ctx, origDial, network, addr)
if err != nil {
return nil, err
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
}
func dialConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
if dialFn != nil {
return dialFn(ctx, network, addr)

View File

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

105
scripts/build-pkg-pr-new.sh Executable file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_DIR="$ROOT_DIR/.pkg-pr-new"
cd "$ROOT_DIR"
python3 scripts/fetch_meta.py
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/bin" "$OUT_DIR/scripts"
VERSION="$(node -p "require('./package.json').version")"
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
SHA="$(git rev-parse --short HEAD)"
LDFLAGS="-s -w -X github.com/larksuite/cli/internal/build.Version=${VERSION}-${SHA} -X github.com/larksuite/cli/internal/build.Date=${DATE}"
build_target() {
local goos="$1"
local goarch="$2"
local ext=""
if [[ "$goos" == "windows" ]]; then
ext=".exe"
fi
local output="$OUT_DIR/bin/lark-cli-${goos}-${goarch}${ext}"
echo "Building ${goos}/${goarch} -> ${output}"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" ./main.go
}
build_target darwin arm64
build_target linux amd64
build_target darwin amd64
build_target linux arm64
build_target windows amd64
build_target windows arm64
cat > "$OUT_DIR/scripts/run.js" <<'RUNJS'
#!/usr/bin/env node
const path = require("path");
const { execFileSync } = require("child_process");
const isWindows = process.platform === "win32";
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
// TODO: Keep broad platform mapping for now; with pkg.pr.new 20MB limit we only ship a subset of binaries.
// Track upstream progress before tightening runtime handling: https://github.com/stackblitz-labs/pkg.pr.new/pull/484
const archMap = {
x64: "amd64",
arm64: "arm64",
};
const platform = platformMap[process.platform];
const arch = archMap[process.arch];
if (!platform || !arch) {
console.error(`Unsupported platform: ${process.platform}-${process.arch}`);
process.exit(1);
}
const ext = isWindows ? ".exe" : "";
const binary = path.join(__dirname, "..", "bin", `lark-cli-${platform}-${arch}${ext}`);
try {
execFileSync(binary, process.argv.slice(2), { stdio: "inherit" });
} catch (err) {
process.exit(err.status || 1);
}
RUNJS
chmod +x "$OUT_DIR/scripts/run.js"
cat > "$OUT_DIR/package.json" <<EOF_JSON
{
"name": "@larksuite/cli",
"version": "${VERSION}-pr.${SHA}",
"description": "The official CLI for Lark/Feishu open platform (PR preview build)",
"bin": {
"lark-cli": "scripts/run.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/larksuite/cli.git"
},
"license": "MIT",
"files": [
"bin",
"scripts/run.js",
"CHANGELOG.md",
"LICENSE"
]
}
EOF_JSON
cp CHANGELOG.md "$OUT_DIR/CHANGELOG.md"
cp LICENSE "$OUT_DIR/LICENSE"
echo "Prepared pkg.pr.new package at $OUT_DIR"

View File

@@ -67,8 +67,24 @@ def main():
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"],
help="API brand (default: feishu)")
parser.add_argument("--force", action="store_true",
help="force refresh from remote even if local file exists")
args = parser.parse_args()
if os.path.exists(OUT_PATH) and not args.force:
if os.path.isfile(OUT_PATH):
try:
with open(OUT_PATH, "r", encoding="utf-8") as fp:
local = json.load(fp)
if local.get("services"):
print(f"fetch-meta: {OUT_PATH} already exists, skipping (use --force to re-fetch)", file=sys.stderr)
return
print(f"fetch-meta: {OUT_PATH} has no services, re-fetching", file=sys.stderr)
except (OSError, json.JSONDecodeError):
print(f"fetch-meta: {OUT_PATH} is invalid JSON, re-fetching", file=sys.stderr)
else:
print(f"fetch-meta: {OUT_PATH} is not a file, re-fetching", file=sys.stderr)
data = fetch_remote(args.brand)
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)

View File

@@ -1,6 +1,5 @@
const fs = require("fs");
const path = require("path");
const https = require("https");
const { execSync } = require("child_process");
const os = require("os");
@@ -32,45 +31,34 @@ if (!platform || !arch) {
const isWindows = process.platform === "win32";
const ext = isWindows ? ".zip" : ".tar.gz";
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`;
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
fs.mkdirSync(binDir, { recursive: true });
function download(url, destPath) {
return new Promise((resolve, reject) => {
const client = url.startsWith("https") ? https : require("http");
client
.get(url, (res) => {
if (res.statusCode === 302 || res.statusCode === 301) {
return download(res.headers.location, destPath).then(
resolve,
reject
);
}
if (res.statusCode !== 200) {
return reject(
new Error(`Download failed with status ${res.statusCode}: ${url}`)
);
}
const file = fs.createWriteStream(destPath);
res.pipe(file);
file.on("finish", () => {
file.close();
resolve();
});
})
.on("error", reject);
});
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
// errors when the certificate revocation list server is unreachable
const sslFlag = isWindows ? "--ssl-revoke-best-effort " : "";
execSync(
`curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`,
{ stdio: ["ignore", "ignore", "pipe"] }
);
}
async function install() {
function install() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
await download(url, archivePath);
try {
download(GITHUB_URL, archivePath);
} catch (err) {
download(MIRROR_URL, archivePath);
}
if (isWindows) {
execSync(
@@ -94,7 +82,14 @@ async function install() {
}
}
install().catch((err) => {
try {
install();
} catch (err) {
console.error(`Failed to install ${NAME}:`, err.message);
console.error(
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli`
);
process.exit(1);
});
}

View File

@@ -0,0 +1,58 @@
# PR Label Sync
This directory contains scripts and sample data for automatically classifying and labeling GitHub Pull Requests based on the files they modify.
## Files
- `index.js`: The main Node.js script. It fetches PR files, evaluates their risk level, calculates business impact, and uses GitHub APIs to add appropriate `size/*` and `domain/*` labels.
- `samples.json`: A collection of historical PRs used as test cases to verify the labeling logic (especially for regression testing the S/M/L thresholds).
## Features
### Size Labels (`size/*`)
The script evaluates the "effective" lines of code changed (ignoring tests, docs, and ci files) to classify the PR:
- **`size/S`**: Low-risk changes involving only docs, tests, CI workflows, or chores.
- **`size/M`**: Small-to-medium changes affecting a single business domain, with effective lines under 300.
- **`size/L`**: Large features (>= 300 lines), cross-domain changes, or any changes touching core architecture paths (like `cmd/`).
- **`size/XL`**: Architectural overhauls, extremely large PRs (>1200 lines), or sensitive refactors.
### Domain Tags (`domain/*`)
The script also identifies which business domains a PR touches to give reviewers an immediate sense of the impact scope. Currently tracked domains include:
- `domain/im`
- `domain/vc`
- `domain/ccm`
- `domain/base`
- `domain/mail`
- `domain/calendar`
- `domain/task`
- `domain/contact`
Minor modules like docs and tests are omitted to keep PR tags clean and focused on structural changes.
## Usage
### In GitHub Actions
This script is designed to run in CI workflows. It automatically reads the `GITHUB_EVENT_PATH` payload to get the PR context.
```bash
node scripts/pr-labels/index.js
```
### Local Dry Run
You can test the labeling logic against an existing GitHub PR without actually applying labels by using the `--dry-run` flag.
```bash
# Requires GITHUB_TOKEN environment variable or passing --token
node scripts/pr-labels/index.js --dry-run --repo larksuite/cli --pr-number 123
```
## Testing
A regression test suite is available in `test.js` which verifies the output of the classification logic against historical PRs configured in `samples.json`.
```bash
# Requires GITHUB_TOKEN environment variable to avoid rate limits
GITHUB_TOKEN=$(gh auth token) node scripts/pr-labels/test.js
```
This test suite also runs automatically in CI via `.github/workflows/pr-labels-test.yml` when changes are made to this directory.

747
scripts/pr-labels/index.js Executable file
View File

@@ -0,0 +1,747 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs/promises");
const path = require("node:path");
// ============================================================================
// Constants & Configuration
// ============================================================================
const API_BASE = "https://api.github.com";
const SCRIPT_DIR = __dirname;
const ROOT = path.join(SCRIPT_DIR, "..", "..");
const THRESHOLD_L = 300;
const THRESHOLD_XL = 1200;
const LABEL_DEFINITIONS = {
"size/S": { color: "77bb00", description: "Low-risk docs, CI, test, or chore only changes" },
"size/M": { color: "eebb00", description: "Single-domain feat or fix with limited business impact" },
"size/L": { color: "ff8800", description: "Large or sensitive change across domains or core paths" },
"size/XL": { color: "ee0000", description: "Architecture-level or global-impact change" },
};
const MANAGED_LABELS = new Set(Object.keys(LABEL_DEFINITIONS));
// File path matching configurations
const DOC_SUFFIXES = [".md", ".mdx", ".txt", ".rst"];
const LOW_RISK_PREFIXES = [".github/", "docs/", ".changeset/", "testdata/", "tests/", "skill-template/"];
const LOW_RISK_FILENAMES = new Set(["readme.md", "readme.zh.md", "changelog.md", "license", "cla.md"]);
const LOW_RISK_TEST_SUFFIXES = ["_test.go", ".snap"];
const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/", "cmd/"];
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
// CODEOWNERS-based path to domain label mapping
// Maps shortcuts and skills paths to business domain labels
const PATH_TO_DOMAIN_MAP = {
// shortcuts
"shortcuts/im/": "im",
"shortcuts/vc/": "vc",
"shortcuts/calendar/": "calendar",
"shortcuts/doc/": "ccm",
"shortcuts/sheets/": "ccm",
"shortcuts/drive/": "ccm",
"shortcuts/base/": "base",
"shortcuts/mail/": "mail",
"shortcuts/task/": "task",
"shortcuts/contact/": "contact",
// skills
"skills/lark-im/": "im",
"skills/lark-vc/": "vc",
"skills/lark-doc/": "ccm",
"skills/lark-base/": "base",
"skills/lark-mail/": "mail",
"skills/lark-calendar/": "calendar",
"skills/lark-task/": "task",
"skills/lark-contact/": "contact",
};
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
const CLASS_STANDARDS = {
"size/S": {
channel: "Fast track (S)",
gates: [
"Code quality: AI code review passed",
"Dependency and configuration security checks passed",
],
},
"size/M": {
channel: "Fast track (M)",
gates: [
"Code quality: AI code review passed",
"Dependency and configuration security checks passed",
"Skill format validation: added or modified Skills load successfully",
"CLI automation tests: all required business-line tests passed",
],
},
"size/L": {
channel: "Standard track (L)",
gates: [
"Code quality: AI code review passed",
"Dependency and configuration security checks passed",
"Skill format validation: added or modified Skills load successfully",
"CLI automation tests: all required business-line tests passed",
"Domain evaluation passed: reported success rate is greater than 95%",
],
},
"size/XL": {
channel: "Strict track (XL)",
gates: [
"Code quality: AI code review passed",
"Dependency and configuration security checks passed",
"Skill format validation: added or modified Skills load successfully",
"CLI automation tests: all required business-line tests passed",
"Domain evaluation passed: reported success rate is greater than 95%",
"Cross-domain release gate: all domains and full integration evaluations passed",
],
},
};
// ============================================================================
// Utilities
// ============================================================================
function log(message) {
console.error(`sync-pr-labels: ${message}`);
}
function normalizePath(input) {
return String(input || "").trim().toLowerCase();
}
function envValue(name) {
return (process.env[name] || "").trim();
}
function envOrFail(name) {
const value = envValue(name);
if (!value) {
throw new Error(`missing required environment variable: ${name}`);
}
return value;
}
// ============================================================================
// GitHub API Client
// ============================================================================
class GitHubClient {
constructor(token, repo, prNumber) {
this.token = token;
this.repo = repo;
this.prNumber = prNumber;
}
buildHeaders(hasBody = false) {
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
if (this.token) {
headers.Authorization = `Bearer ${this.token}`;
}
if (hasBody) {
headers["Content-Type"] = "application/json";
}
return headers;
}
async request(endpoint, options = {}) {
const { method = "GET", payload, allow404 = false } = options;
const hasBody = payload !== undefined;
const url = endpoint.startsWith("http") ? endpoint : `${API_BASE}${endpoint}`;
const response = await fetch(url, {
method,
headers: this.buildHeaders(hasBody),
body: hasBody ? JSON.stringify(payload) : undefined,
});
if (allow404 && response.status === 404) {
return null;
}
if (!response.ok) {
const detail = await response.text();
const error = new Error(`GitHub API ${method} ${url} failed: ${response.status} ${detail}`);
error.status = response.status;
throw error;
}
const text = await response.text();
return text ? JSON.parse(text) : null;
}
async getPullRequest() {
return this.request(`/repos/${this.repo}/pulls/${this.prNumber}`);
}
async listPrFiles() {
const files = [];
for (let page = 1; ; page += 1) {
const params = new URLSearchParams({ per_page: "100", page: String(page) });
const batch = await this.request(`/repos/${this.repo}/pulls/${this.prNumber}/files?${params}`);
if (!batch || batch.length === 0) {
break;
}
files.push(...batch);
if (batch.length < 100) {
break;
}
}
return files;
}
async listIssueLabels() {
const labels = await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels`);
return new Set(labels.map((item) => item.name));
}
async syncLabelDefinition(name) {
const label = LABEL_DEFINITIONS[name];
const createUrl = `/repos/${this.repo}/labels`;
const updateUrl = `/repos/${this.repo}/labels/${encodeURIComponent(name)}`;
try {
await this.request(createUrl, {
method: "POST",
payload: { name, color: label.color, description: label.description },
});
log(`created label ${name}`);
} catch (error) {
if (error.status !== 422) {
throw error;
}
await this.request(updateUrl, {
method: "PATCH",
payload: { new_name: name, color: label.color, description: label.description },
});
log(`updated label ${name}`);
}
}
async addLabels(labels) {
if (labels.length === 0) return;
await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels`, {
method: "POST",
payload: { labels },
});
log(`added labels: ${labels.join(", ")}`);
}
async removeLabel(name) {
await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels/${encodeURIComponent(name)}`, {
method: "DELETE",
allow404: true,
});
log(`removed label: ${name}`);
}
}
// ============================================================================
// Path & Domain Heuristics
// ============================================================================
function parsePrType(title) {
const match = String(title || "").trim().match(/^([a-z]+)(?:\([^)]+\))?!?:/i);
return match ? match[1].toLowerCase() : "";
}
function isLowRiskPath(filePath) {
const normalized = normalizePath(filePath);
const basename = path.posix.basename(normalized);
if (normalized.startsWith("skills/lark-")) return false;
if (DOC_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) return true;
if (LOW_RISK_FILENAMES.has(basename)) return true;
if (LOW_RISK_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return true;
if (LOW_RISK_TEST_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) return true;
return normalized.includes("/testdata/");
}
function isBusinessSkillPath(filePath) {
const normalized = normalizePath(filePath);
return normalized.startsWith("shortcuts/") || normalized.startsWith("skills/lark-");
}
function shortcutDomainForPath(filePath) {
const parts = normalizePath(filePath).split("/");
return parts.length >= 2 && parts[0] === "shortcuts" ? parts[1] : "";
}
function skillDomainForPath(filePath) {
const parts = normalizePath(filePath).split("/");
return parts.length >= 2 && parts[0] === "skills" && parts[1].startsWith("lark-")
? parts[1].slice("lark-".length)
: "";
}
// Get business domain label based on CODEOWNERS path mapping
function getBusinessDomain(filePath) {
const normalized = normalizePath(filePath);
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
if (normalized.startsWith(prefix)) {
return domain;
}
}
return "";
}
async function detectNewShortcutDomain(files) {
for (const item of files) {
if (item.status !== "added") continue;
const domain = shortcutDomainForPath(item.filename);
if (!domain) continue;
try {
await fs.access(path.join(ROOT, "shortcuts", domain));
} catch {
return domain;
}
}
return "";
}
function collectCoreAreas(filenames) {
const areas = new Set();
for (const name of filenames) {
const normalized = normalizePath(name);
for (const prefix of CORE_PREFIXES) {
if (normalized.startsWith(prefix)) {
// remove trailing slash for area name
areas.add(prefix.slice(0, -1));
}
}
}
return areas;
}
function collectSensitiveKeywords(filenames) {
const hits = new Set();
for (const name of filenames) {
const match = normalizePath(name).match(SENSITIVE_PATTERN);
if (match && match[2]) {
hits.add(match[2]);
}
}
return [...hits].sort();
}
// ============================================================================
// Classification Logic
// ============================================================================
function evaluateRules(context) {
const {
prType, effectiveChanges, lowRiskOnly,
domains, headDomains, coreAreas, coreSignals,
sensitiveKeywords, sensitive, newShortcutDomain,
singleDomain, multiDomain, filenames
} = context;
const reasons = [];
let label;
if (lowRiskOnly && (LOW_RISK_TYPES.has(prType) || effectiveChanges === 0)) {
reasons.push("Only low-risk docs, CI, test, or chore paths were changed, with no effective business code or Skill changes");
label = "size/S";
return { label, reasons };
}
// XL is reserved for architecture-level or global-impact changes.
const isXL =
effectiveChanges > THRESHOLD_XL ||
(prType === "refactor" && sensitive && effectiveChanges >= THRESHOLD_L) ||
(coreAreas.size >= 2 && (multiDomain || effectiveChanges >= THRESHOLD_L)) ||
(headDomains.length >= 2 && sensitive);
if (isXL) {
if (effectiveChanges > THRESHOLD_XL) reasons.push("Effective business code or Skill changes are far beyond the L threshold");
if (prType === "refactor" && sensitive && effectiveChanges >= THRESHOLD_L) reasons.push("Refactor PR touches core or sensitive paths");
if (coreAreas.size >= 2) reasons.push("Touches multiple core areas at the same time");
if (headDomains.length >= 2) reasons.push("Impacts multiple major business domains");
coreSignals.forEach((signal) => reasons.push(`Core area hit: ${signal}`));
sensitiveKeywords.forEach((keyword) => reasons.push(`Sensitive keyword hit: ${keyword}`));
label = "size/XL";
} else if (
prType === "refactor" ||
effectiveChanges >= THRESHOLD_L ||
Boolean(newShortcutDomain) ||
multiDomain ||
sensitive
) {
if (prType === "refactor") reasons.push("PR type is refactor");
if (effectiveChanges >= THRESHOLD_L) reasons.push(`Effective business code or Skill changes exceed ${THRESHOLD_L} lines`);
if (newShortcutDomain) reasons.push(`Introduces a new business domain directory: shortcuts/${newShortcutDomain}/`);
if (multiDomain) reasons.push("Touches multiple business domains");
coreSignals.forEach((signal) => reasons.push(`Core area hit: ${signal}`));
sensitiveKeywords.forEach((keyword) => reasons.push(`Sensitive keyword hit: ${keyword}`));
label = "size/L";
} else {
if (filenames.some(isBusinessSkillPath) || effectiveChanges > 0) {
reasons.push("Regular feat, fix, or Skill change within a single business domain");
}
if (singleDomain && domains.size > 0) {
reasons.push(`Impact is limited to a single business domain: ${[...domains].sort().join(", ")}`);
}
if (effectiveChanges < THRESHOLD_L) {
reasons.push(`Effective business code or Skill changes are below ${THRESHOLD_L} lines`);
}
label = "size/M";
}
return { label, reasons };
}
async function classifyPr(payload, files) {
const pr = payload.pull_request;
const title = pr.title || "";
const prType = parsePrType(title);
const filenames = files.map((item) => item.filename || "");
const impactedPaths = files.flatMap((item) => {
const paths = [item.filename || ""];
if (item.status === "renamed" && item.previous_filename) {
paths.push(item.previous_filename);
}
return paths.filter(Boolean);
});
// Filter out docs, tests, and other low-risk paths so the size label tracks business impact.
const effectiveChanges = files.reduce(
(sum, item) => sum + (isLowRiskPath(item.filename) ? 0 : (item.changes || 0)),
0,
);
const totalChanges = files.reduce((sum, item) => sum + (item.changes || 0), 0);
const domains = new Set();
const businessDomains = new Set();
for (const name of impactedPaths) {
const businessDomain = getBusinessDomain(name);
if (businessDomain) {
businessDomains.add(businessDomain);
domains.add(businessDomain);
continue;
}
const shortcutDomain = shortcutDomainForPath(name);
if (shortcutDomain) domains.add(shortcutDomain);
const skillDomain = skillDomainForPath(name);
if (skillDomain) domains.add(skillDomain);
}
const coreAreas = collectCoreAreas(impactedPaths);
const newShortcutDomain = await detectNewShortcutDomain(files);
const lowRiskOnly = impactedPaths.length > 0 && impactedPaths.every(isLowRiskPath);
const singleDomain = domains.size <= 1;
const multiDomain = domains.size >= 2;
const headDomains = [...domains].filter((domain) => HEAD_BUSINESS_DOMAINS.has(domain));
const coreSignals = [...coreAreas].sort();
const sensitiveKeywords = collectSensitiveKeywords(impactedPaths);
const sensitive = coreSignals.length > 0 || sensitiveKeywords.length > 0;
const context = {
prType, effectiveChanges, lowRiskOnly,
domains, headDomains, coreAreas, coreSignals,
sensitiveKeywords, sensitive, newShortcutDomain,
singleDomain, multiDomain, filenames: impactedPaths
};
const { label, reasons } = evaluateRules(context);
return {
label,
title,
prType: prType || "unknown",
totalChanges,
effectiveChanges,
domains: [...domains].sort(),
businessDomains: [...businessDomains].sort(),
coreAreas: [...coreAreas].sort(),
coreSignals,
sensitiveKeywords,
newShortcutDomain,
reasons,
lowRiskOnly,
filenames,
};
}
// ============================================================================
// Output & Formatting
// ============================================================================
async function writeStepSummary(prNumber, classification) {
const summaryPath = (process.env.GITHUB_STEP_SUMMARY || "").trim();
if (!summaryPath) return;
const standard = CLASS_STANDARDS[classification.label];
const domains = classification.domains.join(", ") || "-";
const bDomains = classification.businessDomains.join(", ") || "-";
const coreAreas = classification.coreAreas.join(", ") || "-";
const reasons = classification.reasons.length > 0
? classification.reasons
: ["No higher-severity rule matched, so the PR defaults to medium classification"];
const lines = [
"## PR Size Classification",
"",
`- PR: #${prNumber}`,
`- Label: \`${classification.label}\``,
`- PR Type: \`${classification.prType}\``,
`- Total Changes: \`${classification.totalChanges}\``,
`- Effective Business/SKILL Changes: \`${classification.effectiveChanges}\``,
`- Business Domains: \`${domains}\``,
`- Impacted Domains: \`${bDomains}\``,
`- Core Areas: \`${coreAreas}\``,
`- CI/CD Channel: \`${standard.channel}\``,
`- Low Risk Only: \`${classification.lowRiskOnly}\``,
"",
"### Reasons",
"",
...reasons.map((reason) => `- ${reason}`),
"",
"### Pipeline Gates",
"",
...standard.gates.map((gate) => `- ${gate}`),
"",
];
await fs.appendFile(summaryPath, `${lines.join("\n")}\n`, "utf8");
}
function formatDryRunResult(repo, prNumber, classification) {
const standard = CLASS_STANDARDS[classification.label];
return {
repo,
prNumber,
label: classification.label,
prType: classification.prType,
totalChanges: classification.totalChanges,
effectiveChanges: classification.effectiveChanges,
lowRiskOnly: classification.lowRiskOnly,
domains: classification.domains,
businessDomains: classification.businessDomains,
coreAreas: classification.coreAreas,
coreSignals: classification.coreSignals,
sensitiveKeywords: classification.sensitiveKeywords,
reasons: classification.reasons,
channel: standard.channel,
gates: standard.gates,
};
}
function printDryRunResult(result, options) {
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
const signalParts = [
...result.coreSignals.map((signal) => `core:${signal}`),
...result.sensitiveKeywords.map((keyword) => `keyword:${keyword}`),
...(result.domains.length > 0 ? [`domains:${result.domains.join(",")}`] : []),
];
const reasonParts = result.reasons.length > 0
? result.reasons
: ["No higher-severity rule matched, so the PR defaults to medium classification"];
console.log(
`${result.label} | #${result.prNumber} | type:${result.prType} | eff:${result.effectiveChanges} | `
+ `sig:${signalParts.join(";") || "-"} | reason:${reasonParts.join("; ")}`,
);
}
function printHelp() {
const lines = [
"Usage:",
" node scripts/pr-labels/index.js",
" node scripts/pr-labels/index.js --dry-run --pr-url <github-pr-url> [--token <token>] [--json]",
" node scripts/pr-labels/index.js --dry-run --repo <owner/name> --pr-number <number> [--token <token>] [--json]",
"",
"Modes:",
" default Read the GitHub Actions event payload and apply labels",
" --dry-run Fetch the PR, compute the managed label, and print the result without writing labels",
"",
"Options:",
" --pr-url <url> GitHub pull request URL, for example https://github.com/larksuite/cli/pull/123",
" --repo <owner/name> Repository name, used with --pr-number",
" --pr-number <n> Pull request number, used with --repo",
" --token <token> GitHub token override; falls back to GITHUB_TOKEN",
" --json Print dry-run output as JSON instead of the default one-line summary",
" --help Show this message",
];
console.log(lines.join("\n"));
}
function parseArgs(argv) {
const options = {
dryRun: false,
json: false,
help: false,
prUrl: "",
repo: "",
prNumber: "",
token: "",
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--dry-run") options.dryRun = true;
else if (arg === "--json") options.json = true;
else if (arg === "--help" || arg === "-h") options.help = true;
else if (arg === "--pr-url") options.prUrl = argv[++i] || "";
else if (arg === "--repo") options.repo = argv[++i] || "";
else if (arg === "--pr-number") options.prNumber = argv[++i] || "";
else if (arg === "--token") options.token = argv[++i] || "";
else throw new Error(`unknown argument: ${arg}`);
}
return options;
}
function parsePrUrl(prUrl) {
let parsed;
try {
parsed = new URL(prUrl);
} catch {
throw new Error(`invalid PR URL: ${prUrl}`);
}
const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
if (!match) throw new Error(`unsupported PR URL format: ${prUrl}`);
return { repo: `${match[1]}/${match[2]}`, prNumber: Number(match[3]) };
}
async function loadEventPayload(filePath) {
return JSON.parse(await fs.readFile(filePath, "utf8"));
}
async function resolveContext(options) {
const token = options.token;
if (options.prUrl) {
const { repo, prNumber } = parsePrUrl(options.prUrl);
const client = new GitHubClient(token, repo, prNumber);
const payload = {
repository: { full_name: repo },
pull_request: await client.getPullRequest(),
};
return { repo, prNumber, payload, client };
}
if (options.repo || options.prNumber) {
if (!options.repo || !options.prNumber) throw new Error("--repo and --pr-number must be provided together");
const prNumber = Number(options.prNumber);
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error(`invalid PR number: ${options.prNumber}`);
const client = new GitHubClient(token, options.repo, prNumber);
const payload = {
repository: { full_name: options.repo },
pull_request: await client.getPullRequest(),
};
return { repo: options.repo, prNumber, payload, client };
}
const eventPath = envOrFail("GITHUB_EVENT_PATH");
const payload = await loadEventPayload(eventPath);
const repo = payload.repository.full_name;
const prNumber = payload.pull_request.number;
const client = new GitHubClient(token, repo, prNumber);
return { repo, prNumber, payload, client };
}
// ============================================================================
// Main Execution
// ============================================================================
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
options.token = options.token || envValue("GITHUB_TOKEN");
if (!options.dryRun && !options.token) {
throw new Error("missing required GitHub token; set GITHUB_TOKEN or pass --token");
}
const { repo, prNumber, payload, client } = await resolveContext(options);
const files = await client.listPrFiles();
const classification = await classifyPr(payload, files);
if (options.dryRun) {
printDryRunResult(formatDryRunResult(repo, prNumber, classification), options);
return;
}
const desired = new Set([classification.label]);
for (const domain of classification.businessDomains) {
desired.add(`domain/${domain}`);
}
const current = await client.listIssueLabels();
const managedCurrent = [...current].filter((label) => MANAGED_LABELS.has(label) || label.startsWith("domain/"));
const toAdd = [...desired].filter((label) => !current.has(label)).sort();
const toRemove = managedCurrent.filter((label) => !desired.has(label)).sort();
for (const domain of classification.businessDomains) {
const labelName = `domain/${domain}`;
if (!LABEL_DEFINITIONS[labelName]) {
LABEL_DEFINITIONS[labelName] = { color: "1d76db", description: `PR touches the ${domain} domain` };
}
}
// Ensure labels to be added actually exist in the repository first
// If the label doesn't exist, GitHub API will return 422 Unprocessable Entity when trying to add it to a PR.
for (const label of toAdd) {
if (LABEL_DEFINITIONS[label]) {
try {
await client.syncLabelDefinition(label);
} catch (e) {
log(`Warning: Failed to bootstrap new label ${label}: ${e.message}`);
}
}
}
await client.addLabels(toAdd);
for (const label of toRemove) {
await client.removeLabel(label);
}
// Keep other label metadata consistent. This is best-effort trailing work.
for (const label of Object.keys(LABEL_DEFINITIONS)) {
if (toAdd.includes(label)) continue; // Already synced above
try {
await client.syncLabelDefinition(label);
} catch (e) {
log(`Warning: Failed to sync label definition for ${label}: ${e.message}`);
}
}
await writeStepSummary(prNumber, classification);
log(
`pr #${prNumber} type=${classification.prType} total_changes=${classification.totalChanges} `
+ `effective_changes=${classification.effectiveChanges} files=${files.length} `
+ `desired=${[...desired].sort().join(",") || "-"} current_managed=${managedCurrent.sort().join(",") || "-"} `
+ `reasons=${classification.reasons.join(" | ") || "-"}`,
);
}
main().catch((error) => {
log(error.message || String(error));
process.exit(1);
});

View File

@@ -0,0 +1,145 @@
[
{
"name": "size-s-docs-badge",
"number": 103,
"title": "docs: add official badge to distinguish from third-party Lark CLI tools",
"pr_url": "https://github.com/larksuite/cli/pull/103",
"status": "merged",
"merged_at": "2026-03-30T12:15:45Z",
"expected_label": "size/S",
"expected_domains": [],
"review_note": "Pure docs sample. Useful to confirm low-risk paths stay in S even when total changed lines are not tiny."
},
{
"name": "size-s-docs-simplify",
"number": 26,
"title": "docs: simplify installation steps by merging CLI and Skills into one …",
"pr_url": "https://github.com/larksuite/cli/pull/26",
"status": "merged",
"merged_at": "2026-03-28T09:33:24Z",
"expected_label": "size/S",
"expected_domains": [],
"review_note": "Docs sample, verifying docs changes remain in S."
},
{
"name": "size-s-docs-star-history",
"number": 12,
"title": "docs: add Star History chart to readmes",
"pr_url": "https://github.com/larksuite/cli/pull/12",
"status": "merged",
"merged_at": "2026-03-28T16:00:15Z",
"expected_label": "size/S",
"expected_domains": [],
"review_note": "Docs sample, no effective business code changes."
},
{
"name": "size-s-docs-clarify-install",
"number": 3,
"title": "docs: clarify install methods and add source build steps",
"pr_url": "https://github.com/larksuite/cli/pull/3",
"status": "merged",
"merged_at": "2026-03-28T03:43:44Z",
"expected_label": "size/S",
"expected_domains": [],
"review_note": "Docs sample, pure documentation clarification."
},
{
"name": "size-m-fix-base-scope",
"number": 96,
"title": "fix(base): correct scope for record history list shortcut",
"pr_url": "https://github.com/larksuite/cli/pull/96",
"status": "merged",
"merged_at": "2026-03-30T11:40:18Z",
"expected_label": "size/M",
"expected_domains": ["domain/base"],
"review_note": "Small fix sample. Verify the lower edge of the M bucket within a single domain."
},
{
"name": "size-m-fix-mail-sensitive",
"number": 92,
"title": "fix: remove sensitive send scope from reply and forward shortcuts",
"pr_url": "https://github.com/larksuite/cli/pull/92",
"status": "merged",
"merged_at": "2026-03-30T10:19:11Z",
"expected_label": "size/M",
"expected_domains": ["domain/mail"],
"review_note": "Security-like wording in the title but stays in one business domain (mail)."
},
{
"name": "size-m-ci-improve",
"number": 71,
"title": "ci: improve CI workflows and add golangci-lint config",
"pr_url": "https://github.com/larksuite/cli/pull/71",
"status": "merged",
"merged_at": "2026-03-30T03:09:31Z",
"expected_label": "size/M",
"expected_domains": [],
"review_note": "CI workflow change that goes beyond S threshold."
},
{
"name": "size-m-feat-im-pagination",
"number": 30,
"title": "feat: add auto-pagination to messages search and update lark-im docs",
"pr_url": "https://github.com/larksuite/cli/pull/30",
"status": "merged",
"merged_at": "2026-03-30T15:00:41Z",
"expected_label": "size/M",
"expected_domains": ["domain/im"],
"review_note": "Single-domain feature with larger diff but effective changes stay in M."
},
{
"name": "size-l-fix-api-silent",
"number": 85,
"title": "fix: resolve silent failure in `lark-cli api` error output (#39)",
"pr_url": "https://github.com/larksuite/cli/pull/85",
"status": "merged",
"merged_at": "2026-03-30T09:19:24Z",
"expected_label": "size/L",
"expected_domains": [],
"review_note": "Touches core area (cmd), bumping the size to L."
},
{
"name": "size-l-fix-cli",
"number": 91,
"title": "fix: correct CLI examples in root help and READMEs (closes #48)",
"pr_url": "https://github.com/larksuite/cli/pull/91",
"status": "closed",
"merged_at": null,
"expected_label": "size/L",
"expected_domains": [],
"review_note": "Closed PR touching core area (cmd)."
},
{
"name": "size-m-skill-format-check",
"number": 134,
"title": "feat(ci): add skill format check workflow to ensure SKILL.md compliance",
"pr_url": "https://github.com/larksuite/cli/pull/134",
"status": "closed",
"merged_at": null,
"expected_label": "size/M",
"expected_domains": [],
"review_note": "Includes updates to tests/bad-skill/SKILL.md inside skills-like paths, testing how skill mock files and test scripts are handled."
},
{
"name": "size-l-ccm-multi-path",
"number": 57,
"title": "feat(docs): support local image upload in docs +create",
"pr_url": "https://github.com/larksuite/cli/pull/57",
"status": "closed",
"merged_at": null,
"expected_label": "size/L",
"expected_domains": ["domain/ccm"],
"review_note": "Touches docs_create_images.go and table_auto_width.go, representing multiple CCM sub-paths but resolving to a single ccm domain."
},
{
"name": "size-l-domain-rename",
"number": 11,
"title": "docs: rename user-facing Bitable references to Base",
"pr_url": "https://github.com/larksuite/cli/pull/11",
"status": "merged",
"merged_at": "2026-03-28T16:00:52Z",
"expected_label": "size/L",
"expected_domains": ["domain/base", "domain/ccm"],
"review_note": "A rename across paths. Since we track previous_filename to evaluate domains, this should properly capture the base domain."
}
]

52
scripts/pr-labels/test.js Normal file
View File

@@ -0,0 +1,52 @@
const fs = require('fs');
const { execFileSync } = require('child_process');
const path = require('path');
const samplesPath = path.join(__dirname, 'samples.json');
const indexPath = path.join(__dirname, 'index.js');
const samples = JSON.parse(fs.readFileSync(samplesPath, 'utf8'));
if (!process.env.GITHUB_TOKEN) {
console.error("❌ Error: GITHUB_TOKEN environment variable is required to run tests without hitting API rate limits.");
console.error("Please run: GITHUB_TOKEN=$(gh auth token) node scripts/pr-labels/test.js");
process.exit(1);
}
let passed = 0;
let failed = 0;
for (const sample of samples) {
try {
const output = execFileSync(
process.execPath,
[indexPath, '--dry-run', '--json', '--pr-url', sample.pr_url],
{ encoding: 'utf8', env: process.env }
);
const result = JSON.parse(output);
const matchLabel = result.label === sample.expected_label;
// Sort before comparing to ignore order
const actualDomains = (result.businessDomains || []).sort();
const expectedDomains = (sample.expected_domains || []).map(d => d.replace('domain/', '')).sort();
const matchDomains = JSON.stringify(actualDomains) === JSON.stringify(expectedDomains);
if (matchLabel && matchDomains) {
console.log(`✅ Passed: ${sample.name}`);
passed++;
} else {
console.log(`❌ Failed: ${sample.name}`);
console.log(` Label expected: ${sample.expected_label}, got: ${result.label}`);
console.log(` Domains expected: ${expectedDomains}, got: ${actualDomains}`);
failed++;
}
} catch (e) {
console.log(`❌ Failed: ${sample.name} (Execution error)`);
console.error(e.message);
failed++;
}
}
console.log(`\nTest Summary: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);

View File

@@ -0,0 +1,36 @@
# Skill Format Check
This directory contains a script to validate the format of `SKILL.md` files located in the `../../skills` directory.
## Purpose
The `index.js` script ensures that all `SKILL.md` files conform to the standard template defined in `skill-template/skill-template.md`. Specifically, it checks that the YAML frontmatter includes the following fields:
- `name` (required)
- `description` (required)
- `metadata` (outputs a warning if missing, does not fail the build)
> **Note:** The `lark-shared` skill is explicitly excluded from these format checks.
## Usage
This script is executed automatically via GitHub Actions (`.github/workflows/skill-format-check.yml`) on pull requests and pushes that modify the `skills/` directory.
To run the check manually from the root of the repository, execute:
```bash
node scripts/skill-format-check/index.js
```
You can also specify a custom target directory as the first argument:
```bash
node scripts/skill-format-check/index.js ./path/to/my/skills
```
## Testing
This tool comes with a quick validation script to ensure it correctly identifies good and bad skill formats. To run the tests, execute:
```bash
./scripts/skill-format-check/test.sh
```

View File

@@ -0,0 +1,96 @@
const fs = require('fs');
const path = require('path');
// Allow passing a target directory as the first argument.
// If provided, resolve against process.cwd() so it behaves as the user expects.
// If not provided, default to '../../skills' relative to this script's directory.
const targetDirArg = process.argv[2];
const SKILLS_DIR = targetDirArg
? path.resolve(process.cwd(), targetDirArg)
: path.resolve(__dirname, '../../skills');
function checkSkillFormat() {
console.log(`Checking skill format in ${SKILLS_DIR}...`);
if (!fs.existsSync(SKILLS_DIR)) {
console.error('Skills directory not found:', SKILLS_DIR);
process.exit(1);
}
let skills;
try {
skills = fs
.readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
} catch (err) {
console.error(`Failed to enumerate skills directory: ${err.message}`);
process.exit(1);
}
let hasErrors = false;
skills.forEach(skill => {
// Skip lark-shared skill completely
if (skill === 'lark-shared') {
console.log(`⏭️ Skipping check for ${skill}`);
return;
}
const skillPath = path.join(SKILLS_DIR, skill);
const skillFile = path.join(skillPath, 'SKILL.md');
if (!fs.existsSync(skillFile)) {
console.error(`❌ [${skill}] Missing SKILL.md`);
hasErrors = true;
return;
}
let content;
try {
content = fs.readFileSync(skillFile, 'utf-8');
} catch (err) {
console.error(`❌ [${skill}] Failed to read SKILL.md: ${err.message}`);
hasErrors = true;
return;
}
// Normalize line endings to simplify parsing
const normalizedContent = content.replace(/\r\n/g, '\n');
// Check YAML Frontmatter
if (!normalizedContent.startsWith('---\n')) {
console.error(`❌ [${skill}] SKILL.md must start with YAML frontmatter (---)`);
hasErrors = true;
} else {
const frontmatterMatch = normalizedContent.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
if (!frontmatterMatch) {
console.error(`❌ [${skill}] SKILL.md has unclosed or invalid YAML frontmatter`);
hasErrors = true;
} else {
const frontmatter = frontmatterMatch[1];
if (!/^name:/m.test(frontmatter)) {
console.error(`❌ [${skill}] YAML frontmatter missing 'name'`);
hasErrors = true;
}
if (!/^description:/m.test(frontmatter)) {
console.error(`❌ [${skill}] YAML frontmatter missing 'description'`);
hasErrors = true;
}
if (!/^metadata:/m.test(frontmatter)) {
console.warn(`⚠️ [${skill}] YAML frontmatter missing 'metadata' (Warning only)`);
// hasErrors = true; // Downgrade to warning to not fail on existing skills
}
}
}
});
if (hasErrors) {
console.error('\n❌ Skill format check failed. Please fix the errors above.');
process.exit(1);
} else {
console.log('\n✅ Skill format check passed!');
}
}
checkSkillFormat();

View File

@@ -0,0 +1,82 @@
#!/bin/bash
# Get the directory of this script
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
INDEX_JS="$DIR/index.js"
TEMP_DIR="$DIR/tests/temp_test_dir"
echo "=== Running tests for skill-format-check ==="
echo "Index script: $INDEX_JS"
prepare_fixture() {
local test_name=$1
rm -rf "$TEMP_DIR"
mkdir -p "$TEMP_DIR"
if [ ! -d "$DIR/tests/$test_name" ]; then
echo "❌ Missing fixture directory: $DIR/tests/$test_name"
exit 1
fi
cp -r "$DIR/tests/$test_name" "$TEMP_DIR/" || {
echo "❌ Failed to copy fixture: $test_name"
exit 1
}
}
# Function to run a positive test
run_positive_test() {
local test_name=$1
echo -e "\n--- [Positive] $test_name ---"
prepare_fixture "$test_name"
node "$INDEX_JS" "$TEMP_DIR"
if [ $? -eq 0 ]; then
echo "✅ Passed! (Correctly validated $test_name)"
rm -rf "$TEMP_DIR"
return 0
else
echo "❌ Failed! Expected $test_name to pass but it failed."
rm -rf "$TEMP_DIR"
exit 1
fi
}
# Function to run a negative test
run_negative_test() {
local test_name=$1
echo -e "\n--- [Negative] $test_name ---"
prepare_fixture "$test_name"
# Capture output for diagnostics while still treating non-zero as expected
local log_file="$TEMP_DIR/.validator.log"
node "$INDEX_JS" "$TEMP_DIR" > "$log_file" 2>&1
local exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "✅ Passed! (Correctly rejected $test_name)"
rm -rf "$TEMP_DIR"
return 0
else
echo "❌ Failed! Expected $test_name to fail but it passed."
if [ -s "$log_file" ]; then
echo "--- Validator output ---"
cat "$log_file"
fi
rm -rf "$TEMP_DIR"
exit 1
fi
}
# Run positive tests
run_positive_test "good-skill"
run_positive_test "good-skill-minimal"
run_positive_test "good-skill-complex"
# Run negative tests
run_negative_test "bad-skill"
run_negative_test "bad-skill-no-frontmatter"
run_negative_test "bad-skill-unclosed-frontmatter"
echo -e "\n🎉 All tests passed successfully!"

View File

@@ -0,0 +1,3 @@
# No Frontmatter Skill
This skill completely lacks a YAML frontmatter.

View File

@@ -0,0 +1,9 @@
---
name: bad-skill-unclosed
version: 1.0.0
description: "This skill has an unclosed frontmatter block."
metadata: {}
# Unclosed Frontmatter Skill
This frontmatter does not have a closing `---` block.

View File

@@ -0,0 +1,8 @@
---
version: 1.0.0
metadata: {}
---
# Bad Skill
This skill is missing required fields like name and description.

View File

@@ -0,0 +1,17 @@
---
name: good-skill-complex
version: 2.5.1-beta
description: >
A very complex description
that spans multiple lines
and contains weird chars: !@#$%^&*()
metadata:
requires:
bins: ["lark-cli", "node"]
cliHelp: "lark-cli something --help"
customField: "customValue"
---
# Complex Skill
This skill has a complex frontmatter block.

View File

@@ -0,0 +1,10 @@
---
name: good-skill-minimal
version: 0.1.0
description: Minimal valid description
metadata: {}
---
# Minimal Skill
This has the bare minimum required fields.

View File

@@ -0,0 +1,12 @@
---
name: good-skill
version: 1.0.0
description: "This is a properly formatted skill."
metadata:
requires:
bins: ["lark-cli"]
---
# Good Skill
This skill follows all the formatting rules.

View File

@@ -14,7 +14,7 @@ import (
var BaseDataQuery = common.Shortcut{
Service: "base",
Command: "+data-query",
Description: "Query and analyze Bitable data with JSON DSL (aggregation, filter, sort)",
Description: "Query and analyze Base data with JSON DSL (aggregation, filter, sort)",
Risk: "read",
Scopes: []string{"base:table:read"},
AuthTypes: authTypes(),

View File

@@ -14,7 +14,7 @@ var BaseRecordHistoryList = common.Shortcut{
Command: "+record-history-list",
Description: "List record change history",
Risk: "read",
Scopes: []string{"base:record:read"},
Scopes: []string{"base:history:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var CalendarRsvp = common.Shortcut{
Service: "calendar",
Command: "+rsvp",
Description: "Reply to a calendar event (accept/decline/tentative)",
Risk: "write",
Scopes: []string{"calendar:calendar.event:reply"},
AuthTypes: []string{"user", "bot"},
HasFormat: false,
Flags: []common.Flag{
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "event-id", Desc: "event ID", Required: true},
{Name: "rsvp-status", Desc: "reply status", Required: true, Enum: []string{"accept", "decline", "tentative"}},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
d := common.NewDryRunAPI()
switch calendarId {
case "":
d.Desc("(calendar-id omitted) Will use primary calendar")
calendarId = "<primary>"
case "primary":
calendarId = "<primary>"
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
status := strings.TrimSpace(runtime.Str("rsvp-status"))
return d.
POST("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id/reply").
Body(map[string]interface{}{"rsvp_status": status}).
Set("calendar_id", calendarId).
Set("event_id", eventId)
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
for _, flag := range []string{"calendar-id", "event-id", "rsvp-status"} {
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
}
}
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
if eventId == "" {
return output.ErrValidation("event-id cannot be empty")
}
return nil
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
if calendarId == "" {
calendarId = PrimaryCalendarIDStr
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
status := strings.TrimSpace(runtime.Str("rsvp-status"))
_, err := runtime.DoAPIJSON("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/reply",
validate.EncodePathSegment(calendarId),
validate.EncodePathSegment(eventId)),
nil,
map[string]interface{}{
"rsvp_status": status,
})
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"calendar_id": calendarId,
"event_id": eventId,
"rsvp_status": status,
}, nil)
return nil
},
}

View File

@@ -580,6 +580,118 @@ func TestFreebusy_APIError(t *testing.T) {
// CalendarSuggestion tests
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// CalendarRsvp tests
// ---------------------------------------------------------------------------
func TestRsvp_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/primary/events/evt_rsvp1/reply",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
},
})
err := mountAndRun(t, CalendarRsvp, []string{
"+rsvp",
"--event-id", "evt_rsvp1",
"--rsvp-status", "accept",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, want := range []string{`"event_id": "evt_rsvp1"`, `"rsvp_status": "accept"`} {
if !strings.Contains(stdout.String(), want) {
t.Errorf("stdout should contain %s, got: %s", want, stdout.String())
}
}
}
func TestRsvp_InvalidStatus(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarRsvp, []string{
"+rsvp",
"--event-id", "evt_rsvp1",
"--rsvp-status", "invalid_status",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for invalid status, got nil")
}
if !strings.Contains(err.Error(), "invalid value") {
t.Errorf("error should mention invalid value, got: %v", err)
}
}
func TestRsvp_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/primary/events/evt_rsvp1/reply",
Body: map[string]interface{}{
"code": 190001,
"msg": "permission denied",
},
})
err := mountAndRun(t, CalendarRsvp, []string{
"+rsvp",
"--event-id", "evt_rsvp1",
"--rsvp-status", "decline",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for API failure, got nil")
}
}
func TestRsvp_RejectsDangerousChars(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarRsvp, []string{
"+rsvp",
"--event-id", "evt_rsvp1\u202e",
"--rsvp-status", "accept",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for dangerous characters, got nil")
}
if !strings.Contains(err.Error(), "dangerous Unicode") && !strings.Contains(err.Error(), "control character") {
t.Errorf("error should mention dangerous input, got: %v", err)
}
}
func TestRsvp_DryRun_TrimmedPrimaryCalendar(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarRsvp, []string{
"+rsvp",
"--calendar-id", " primary ",
"--event-id", "evt_rsvp1",
"--rsvp-status", "accept",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"calendar_id": "\u003cprimary\u003e"`) {
t.Errorf("dry-run should normalize primary calendar, got: %s", stdout.String())
}
}
func TestSuggestion_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
@@ -867,17 +979,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test
// ---------------------------------------------------------------------------
func TestShortcuts_Returns4(t *testing.T) {
func TestShortcuts_Returns5(t *testing.T) {
shortcuts := Shortcuts()
if len(shortcuts) != 4 {
t.Fatalf("expected 4 shortcuts, got %d", len(shortcuts))
if len(shortcuts) != 5 {
t.Fatalf("expected 5 shortcuts, got %d", len(shortcuts))
}
names := map[string]bool{}
for _, s := range shortcuts {
names[s.Command] = true
}
for _, want := range []string{"+agenda", "+create", "+freebusy", "+suggestion"} {
for _, want := range []string{"+agenda", "+create", "+freebusy", "+rsvp", "+suggestion"} {
if !names[want] {
t.Errorf("missing shortcut %s", want)
}

View File

@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
CalendarAgenda,
CalendarCreate,
CalendarFreebusy,
CalendarRsvp,
CalendarSuggestion,
}
}

View File

@@ -33,6 +33,8 @@ type RuntimeContext struct {
Config *core.CliConfig
Cmd *cobra.Command
Format string
JqExpr string // --jq expression; empty = no filter
outputErr error // deferred error from Out()/OutFormat() jq filtering
botOnly bool // set by framework for bot-only shortcuts
resolvedAs core.Identity // effective identity resolved by framework
Factory *cmdutil.Factory // injected by framework
@@ -225,6 +227,20 @@ func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestO
return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...)
}
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
// regardless of the current --as flag. Use this for bot-only APIs (e.g. image/file upload)
// that must be called with TAT even when the surrounding shortcut runs as user.
func (ctx *RuntimeContext) DoAPIAsBot(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
ac, err := ctx.getAPIClient()
if err != nil {
return nil, err
}
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
opts = append(opts, optFn)
}
return ac.DoSDKRequest(ctx.ctx, req, core.AsBot, opts...)
}
type cancelOnCloseReadCloser struct {
io.ReadCloser
cancel context.CancelFunc
@@ -418,14 +434,28 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams {
// Out prints a success JSON envelope to stdout.
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta}
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
if ctx.JqExpr != "" {
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
if ctx.outputErr == nil {
ctx.outputErr = err
}
}
return
}
b, _ := json.MarshalIndent(env, "", " ")
fmt.Fprintln(ctx.IO().Out, string(b))
}
// OutFormat prints output based on --format flag.
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
// When JqExpr is set, routes through Out() regardless of format.
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
if ctx.JqExpr != "" {
ctx.Out(data, meta)
return
}
switch ctx.Format {
case "pretty":
if prettyFn != nil {
@@ -546,6 +576,9 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
if err := validateEnumFlags(rctx, s.Flags); err != nil {
return err
}
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
return err
}
if s.Validate != nil {
if err := s.Validate(rctx.ctx, rctx); err != nil {
return err
@@ -562,7 +595,10 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
}
}
return s.Execute(rctx.ctx, rctx)
if err := s.Execute(rctx.ctx, rctx); err != nil {
return err
}
return rctx.outputErr
}
func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) {
@@ -604,6 +640,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
if s.HasFormat {
rctx.Format = rctx.Str("format")
}
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
return rctx, nil
}
@@ -684,6 +721,7 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot")
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {

View File

@@ -0,0 +1,201 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"context"
"io"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// newJqTestContext creates a RuntimeContext wired for jq testing.
func newJqTestContext(jqExpr, format string) (*RuntimeContext, *bytes.Buffer, *bytes.Buffer) {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("jq", "", "")
cmd.Flags().String("format", "json", "")
cmd.Flags().String("as", "bot", "")
cmd.ParseFlags(nil)
if jqExpr != "" {
cmd.Flags().Set("jq", jqExpr)
}
if format != "" {
cmd.Flags().Set("format", format)
}
rctx := &RuntimeContext{
ctx: context.Background(),
Config: &core.CliConfig{Brand: core.BrandFeishu},
Cmd: cmd,
Format: format,
JqExpr: jqExpr,
resolvedAs: core.AsBot,
Factory: &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr},
},
}
return rctx, stdout, stderr
}
func TestRuntimeContext_Out_WithJq(t *testing.T) {
rctx, stdout, _ := newJqTestContext(".data.name", "")
rctx.Out(map[string]interface{}{
"name": "Alice",
"age": 30,
}, nil)
out := stdout.String()
if !strings.Contains(out, "Alice") {
t.Errorf("expected jq-filtered 'Alice', got: %s", out)
}
if strings.Contains(out, "age") {
t.Errorf("expected jq to filter out 'age', got: %s", out)
}
}
func TestRuntimeContext_Out_WithJq_Identity(t *testing.T) {
rctx, stdout, _ := newJqTestContext(".ok", "")
rctx.Out(map[string]interface{}{"key": "value"}, nil)
out := strings.TrimSpace(stdout.String())
if out != "true" {
t.Errorf("expected 'true' for .ok, got: %s", out)
}
}
func TestRuntimeContext_OutFormat_WithJq_OverridesFormat(t *testing.T) {
rctx, stdout, _ := newJqTestContext(".data.items", "pretty")
items := []interface{}{"a", "b", "c"}
rctx.OutFormat(map[string]interface{}{
"items": items,
}, nil, func(w io.Writer) {
t.Error("prettyFn should not be called when jq is set")
})
out := stdout.String()
if !strings.Contains(out, "a") || !strings.Contains(out, "b") {
t.Errorf("expected jq-filtered items, got: %s", out)
}
}
func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) {
rctx, _, stderr := newJqTestContext(".foo | invalid_func_xyz", "")
rctx.Out(map[string]interface{}{"foo": "bar"}, nil)
if !strings.Contains(stderr.String(), "error") {
t.Errorf("expected error on stderr for runtime jq error, got: %s", stderr.String())
}
}
func newTestShortcutCmd(s *Shortcut) *cobra.Command {
cmd := &cobra.Command{Use: "test-shortcut"}
cmd.SetContext(context.Background())
registerShortcutFlags(cmd, s)
return cmd
}
func newTestFactory() *cmdutil.Factory {
return &cmdutil.Factory{
Config: func() (*core.CliConfig, error) {
return &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
}, nil
},
LarkClient: func() (*lark.Client, error) {
return lark.NewClient("test", "test"), nil
},
IOStreams: &cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}},
}
}
func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
HasFormat: true,
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
return nil
},
}
cmd := newTestShortcutCmd(s)
cmd.Flags().Set("jq", ".data")
cmd.Flags().Set("format", "table")
cmd.Flags().Set("as", "bot")
err := runShortcut(cmd, newTestFactory(), s, true)
if err == nil {
t.Fatal("expected error for --jq + --format table conflict")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
}
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
return nil
},
}
cmd := newTestShortcutCmd(s)
cmd.Flags().Set("jq", "invalid[")
cmd.Flags().Set("as", "bot")
err := runShortcut(cmd, newTestFactory(), s, true)
if err == nil {
t.Fatal("expected error for invalid jq expression")
}
if !strings.Contains(err.Error(), "invalid jq expression") {
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
}
}
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
rctx.Out(map[string]interface{}{"foo": "bar"}, nil)
return nil
},
}
cmd := newTestShortcutCmd(s)
cmd.Flags().Set("jq", ".foo | invalid_func_xyz")
cmd.Flags().Set("as", "bot")
err := runShortcut(cmd, newTestFactory(), s, true)
if err == nil {
t.Fatal("expected error from jq runtime failure to propagate")
}
}
func TestRuntimeContext_Out_WithoutJq_NormalOutput(t *testing.T) {
rctx, stdout, _ := newJqTestContext("", "")
rctx.Out(map[string]interface{}{"key": "value"}, &output.Meta{Count: 1})
out := stdout.String()
if !strings.Contains(out, `"ok"`) || !strings.Contains(out, `"key"`) {
t.Errorf("expected normal JSON envelope, got: %s", out)
}
}

View File

@@ -0,0 +1,245 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// DriveExport exports Drive-native documents to local files and falls back to
// a follow-up command when the async export task does not finish in time.
var DriveExport = common.Shortcut{
Service: "drive",
Command: "+export",
Description: "Export a doc/docx/sheet/bitable to a local file with limited polling",
Risk: "read",
Scopes: []string{
"docs:document.content:read",
"docs:document:export",
"drive:drive.metadata:readonly",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "token", Desc: "source document token", Required: true},
{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", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown"}},
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveExportSpec(driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
}
// 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().
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",
})
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
return common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
}
outputDir := runtime.Str("output-dir")
overwrite := runtime.Bool("overwrite")
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
data, err := runtime.CallAPI(
"GET",
"/open-apis/docs/v1/content",
map[string]interface{}{
"doc_token": spec.Token,
"doc_type": "docx",
"content_type": "markdown",
},
nil,
)
if err != nil {
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 := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len([]byte(common.GetString(data, "content"))),
}, nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(driveExportPollInterval):
}
}
if err := ctx.Err(); err != nil {
return err
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
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)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
}
return output.ErrWithHint(output.ExitAPI, "api_error", err.Error(), hint)
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
return nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return output.Errorf(output.ExitAPI, "api_error", "export task failed: %s (ticket=%s)", msg, ticket)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
}
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastPollErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
}
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
}
return output.ErrWithHint(output.ExitAPI, "api_error", lastPollErr.Error(), hint)
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// 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{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
},
}

View File

@@ -0,0 +1,371 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
driveExportPollAttempts = 10
driveExportPollInterval = 5 * time.Second
)
// driveExportSpec contains the normalized export request understood by the
// shortcut and the underlying export task APIs.
type driveExportSpec struct {
Token string
DocType string
FileExtension string
SubID string
}
// driveExportTaskResultCommand prints the resume command shown when bounded
// export polling times out locally.
func driveExportTaskResultCommand(ticket, docToken string) string {
return fmt.Sprintf("lark-cli drive +task_result --scenario export --ticket %s --file-token %s", ticket, docToken)
}
// driveExportDownloadCommand prints a copy-pasteable follow-up command for
// downloading an already-generated export artifact by file token.
func driveExportDownloadCommand(fileToken, fileName, outputDir string, overwrite bool) string {
parts := []string{
"lark-cli", "drive", "+export-download",
"--file-token", strconv.Quote(fileToken),
}
if strings.TrimSpace(fileName) != "" {
parts = append(parts, "--file-name", strconv.Quote(fileName))
}
if strings.TrimSpace(outputDir) != "" && outputDir != "." {
parts = append(parts, "--output-dir", strconv.Quote(outputDir))
}
if overwrite {
parts = append(parts, "--overwrite")
}
return strings.Join(parts, " ")
}
// driveExportStatus captures the fields needed to decide whether the export is
// ready for download, still pending, or terminally failed.
type driveExportStatus struct {
Ticket string
FileExtension string
DocType string
FileName string
FileToken string
JobErrorMsg string
FileSize int64
JobStatus int
}
func (s driveExportStatus) Ready() bool {
return s.FileToken != "" && s.JobStatus == 0
}
func (s driveExportStatus) Pending() bool {
// A zero status without a file token is still in progress because there is
// nothing downloadable yet.
return s.JobStatus == 1 || s.JobStatus == 2 || s.JobStatus == 0 && s.FileToken == ""
}
func (s driveExportStatus) Failed() bool {
return !s.Ready() && !s.Pending() && s.JobStatus != 0
}
func (s driveExportStatus) StatusLabel() string {
switch s.JobStatus {
case 0:
// Success is a special case where the file token is set.
if s.FileToken != "" {
return "success"
}
return "pending"
case 1:
return "new"
case 2:
return "processing"
case 3:
return "internal_error"
case 107:
return "export_size_limit"
case 108:
return "timeout"
case 109:
return "export_block_not_permitted"
case 110:
return "no_permission"
case 111:
return "docs_deleted"
case 122:
return "export_denied_on_copying"
case 123:
return "docs_not_exist"
case 6000:
return "export_images_exceed_limit"
default:
return fmt.Sprintf("status_%d", s.JobStatus)
}
}
// validateDriveExportSpec enforces shortcut-level export constraints before any
// backend request is sent.
func validateDriveExportSpec(spec driveExportSpec) error {
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
return output.ErrValidation("%s", err)
}
switch spec.DocType {
case "doc", "docx", "sheet", "bitable":
default:
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable", spec.DocType)
}
switch spec.FileExtension {
case "docx", "pdf", "xlsx", "csv", "markdown":
default:
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown", spec.FileExtension)
}
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
return output.ErrValidation("--file-extension markdown only supports --doc-type docx")
}
if strings.TrimSpace(spec.SubID) != "" {
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
}
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
return output.ErrValidation("%s", err)
}
}
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
return output.ErrValidation("--sub-id is required when exporting sheet/bitable as csv")
}
return nil
}
// createDriveExportTask starts the asynchronous export job and returns its
// ticket for subsequent polling.
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {
return "", err
}
ticket := common.GetString(data, "ticket")
if ticket == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "export task created but ticket is missing")
}
return ticket, nil
}
// getDriveExportStatus fetches the current backend state for a previously
// created export task.
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
map[string]interface{}{"token": token},
nil,
)
if err != nil {
return driveExportStatus{}, err
}
return parseDriveExportStatus(ticket, data), nil
}
// parseDriveExportStatus accepts the wrapped export result and normalizes the
// subset of fields used by the shortcut.
func parseDriveExportStatus(ticket string, data map[string]interface{}) driveExportStatus {
result := common.GetMap(data, "result")
status := driveExportStatus{
Ticket: ticket,
}
if result == nil {
// Keep the ticket even when the result body is missing so callers can
// still show a resumable task reference.
return status
}
status.FileExtension = common.GetString(result, "file_extension")
status.DocType = common.GetString(result, "type")
status.FileName = common.GetString(result, "file_name")
status.FileToken = common.GetString(result, "file_token")
status.JobErrorMsg = common.GetString(result, "job_error_msg")
status.FileSize = int64(common.GetFloat(result, "file_size"))
status.JobStatus = int(common.GetFloat(result, "job_status"))
return status
}
// fetchDriveMetaTitle looks up the document title so exported files can use a
// human-readable default name when possible.
func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType string) (string, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": token,
"doc_type": docType,
},
},
},
)
if err != nil {
return "", err
}
metas := common.GetSlice(data, "metas")
if len(metas) == 0 {
return "", nil
}
meta, _ := metas[0].(map[string]interface{})
return common.GetString(meta, "title"), nil
}
// saveContentToOutputDir validates the target path, enforces overwrite policy,
// and writes the payload atomically to disk.
func saveContentToOutputDir(outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
if outputDir == "" {
outputDir = "."
}
// Sanitize both the filename and the combined output path so caller-provided
// names cannot escape the requested output directory.
safeName := sanitizeExportFileName(fileName, "export.bin")
target := filepath.Join(outputDir, safeName)
safePath, err := validate.SafeOutputPath(target)
if err != nil {
return "", output.ErrValidation("unsafe output path: %s", err)
}
if err := common.EnsureWritableFile(safePath, overwrite); err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "cannot create output directory: %s", err)
}
if err := validate.AtomicWrite(safePath, payload, 0644); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "cannot write file: %s", err)
}
return safePath, nil
}
// downloadDriveExportFile downloads the exported artifact, derives a safe local
// file name, and returns metadata about the saved file.
func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outputDir, preferredName string, overwrite bool) (map[string]interface{}, error) {
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return nil, output.ErrValidation("%s", err)
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
}, larkcore.WithFileDownload())
if err != nil {
return nil, output.ErrNetwork("download failed: %s", err)
}
if apiResp.StatusCode >= 400 {
return nil, output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
}
fileName := strings.TrimSpace(preferredName)
if fileName == "" {
// Fall back to the server-provided download name when the caller did not
// request an explicit local file name.
fileName = client.ResolveFilename(apiResp)
}
savedPath, err := saveContentToOutputDir(outputDir, fileName, apiResp.RawBody, overwrite)
if err != nil {
return nil, err
}
return map[string]interface{}{
"file_token": fileToken,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(apiResp.RawBody),
"content_type": apiResp.Header.Get("Content-Type"),
}, nil
}
// sanitizeExportFileName strips path traversal and unsupported characters while
// preserving a readable file name when possible.
func sanitizeExportFileName(name, fallback string) string {
name = strings.TrimSpace(filepath.Base(name))
if name == "" || name == "." || name == string(filepath.Separator) {
name = fallback
}
replacer := strings.NewReplacer(
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_",
"\"", "_", "<", "_", ">", "_", "|", "_",
"\n", "_", "\r", "_", "\t", "_", "\x00", "_",
)
name = replacer.Replace(name)
name = strings.Trim(name, ". ")
if name == "" {
return fallback
}
return name
}
// ensureExportFileExtension appends the expected local suffix when the chosen
// file name does not already end with the export format's extension.
func ensureExportFileExtension(name, fileExtension string) string {
expected := exportFileSuffix(fileExtension)
if expected == "" {
return name
}
if strings.EqualFold(filepath.Ext(name), expected) {
return name
}
return name + expected
}
// exportFileSuffix maps shortcut-level export formats to the local filename
// suffix written to disk.
func exportFileSuffix(fileExtension string) string {
switch fileExtension {
case "markdown":
return ".md"
case "docx":
return ".docx"
case "pdf":
return ".pdf"
case "xlsx":
return ".xlsx"
case "csv":
return ".csv"
default:
return ""
}
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import "testing"
func TestDriveExportStatusLabelCoversKnownAndUnknownCodes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
status driveExportStatus
want string
}{
{
name: "size limit",
status: driveExportStatus{JobStatus: 107},
want: "export_size_limit",
},
{
name: "not exist",
status: driveExportStatus{JobStatus: 123},
want: "docs_not_exist",
},
{
name: "unknown status",
status: driveExportStatus{JobStatus: 999},
want: "status_999",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.status.StatusLabel(); got != tt.want {
t.Fatalf("StatusLabel() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseDriveExportStatusWithoutResultKeepsTicket(t *testing.T) {
t.Parallel()
status := parseDriveExportStatus("ticket_export_test", map[string]interface{}{})
if status.Ticket != "ticket_export_test" {
t.Fatalf("ticket = %q, want %q", status.Ticket, "ticket_export_test")
}
if status.FileToken != "" {
t.Fatalf("file token = %q, want empty", status.FileToken)
}
}
func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
t.Parallel()
if got := sanitizeExportFileName("../quarterly:report?.pdf", "fallback.bin"); got != "quarterly_report_.pdf" {
t.Fatalf("sanitizeExportFileName() = %q, want %q", got, "quarterly_report_.pdf")
}
if got := ensureExportFileExtension("meeting-notes", "markdown"); got != "meeting-notes.md" {
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "meeting-notes.md")
}
if got := ensureExportFileExtension("report.pdf", "pdf"); got != "report.pdf" {
t.Fatalf("ensureExportFileExtension() should preserve suffix, got %q", got)
}
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// DriveExportDownload downloads an already-generated export artifact when the
// caller has a file token from a previous export task.
var DriveExportDownload = common.Shortcut{
Service: "drive",
Command: "+export-download",
Description: "Download an exported file by file_token",
Risk: "read",
Scopes: []string{
"docs:document:export",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "exported file token", Required: true},
{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"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/drive/v1/export_tasks/file/:file_token/download").
Set("file_token", runtime.Str("file-token")).
Set("output_dir", runtime.Str("output-dir"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Reuse the shared export download helper so overwrite checks, filename
// resolution, and output metadata stay consistent with drive +export.
out, err := downloadDriveExportFile(
ctx,
runtime,
runtime.Str("file-token"),
runtime.Str("output-dir"),
runtime.Str("file-name"),
runtime.Bool("overwrite"),
)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}

View File

@@ -0,0 +1,516 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bytes"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestValidateDriveExportSpec(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec driveExportSpec
wantErr string
}{
{
name: "markdown docx ok",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
},
{
name: "markdown non docx rejected",
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
wantErr: "only supports --doc-type docx",
},
{
name: "csv without sub id rejected",
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "csv"},
wantErr: "--sub-id is required",
},
{
name: "sub id on non csv rejected",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pdf", SubID: "tbl_1"},
wantErr: "--sub-id is only used",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveExportSpec(tt.spec)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}
func TestDriveExportMarkdownWritesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# hello\n",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "Weekly Notes"},
},
},
},
})
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, "Weekly Notes.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "# hello\n" {
t.Fatalf("markdown content = %q", string(data))
}
if !strings.Contains(stdout.String(), "Weekly Notes.md") {
t.Fatalf("stdout missing file name: %s", stdout.String())
}
}
func TestDriveExportAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
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_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_123",
"file_name": "report",
"file_extension": "pdf",
"type": "docx",
"file_size": 3,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_123/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="report.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",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "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(), `"ticket": "tk_123"`) {
t.Fatalf("stdout missing ticket: %s", stdout.String())
}
}
func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
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_ready"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_ready",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_ready",
"file_name": "report",
"file_extension": "pdf",
"type": "docx",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_ready/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "report.pdf"), []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
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",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected download recovery error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if !strings.Contains(exitErr.Detail.Message, "already exists") {
t.Fatalf("message missing overwrite guidance: %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_ready") {
t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "file_token=box_ready") {
t.Fatalf("hint missing file token: %q", exitErr.Detail.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) {
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
}
}
func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
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_456"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_456",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 2,
},
},
},
})
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",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"ticket": "tk_456"`) {
t.Fatalf("stdout missing ticket: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"timed_out": true`) {
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"failed": false`) {
t.Fatalf("stdout missing failed=false: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"job_status": 2`) {
t.Fatalf("stdout missing numeric job_status: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"job_status_label": "processing"`) {
t.Fatalf("stdout missing processing job_status_label: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"next_command": "lark-cli drive +task_result --scenario export --ticket tk_456 --file-token docx123"`) {
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
}
if _, err := os.Stat(filepath.Join(tmpDir, "report.pdf")); !os.IsNotExist(err) {
t.Fatalf("unexpected downloaded file, err=%v", err)
}
}
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
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_poll_fail"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_poll_fail",
Status: 500,
Body: map[string]interface{}{
"code": 999,
"msg": "temporary backend failure",
},
})
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",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected persistent poll error, got nil")
}
if stdout.Len() != 0 {
t.Fatalf("stdout should stay empty on persistent poll error: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if !strings.Contains(exitErr.Detail.Message, "temporary backend failure") {
t.Fatalf("message missing last poll error: %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_poll_fail") {
t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") {
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
}
}
func TestDriveExportDownloadUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_789/download",
Status: 200,
RawBody: []byte("csv"),
Headers: http.Header{
"Content-Type": []string{"text/csv"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExportDownload, []string{
"+export-download",
"--file-token", "box_789",
"--file-name", "custom.csv",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom.csv"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "csv" {
t.Fatalf("downloaded content = %q", string(data))
}
}
func TestDriveExportDownloadRejectsOverwriteWithoutFlag(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_dup/download",
Status: 200,
RawBody: []byte("new"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="dup.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("dup.pdf", []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveExportDownload, []string{
"+export-download",
"--file-token", "box_dup",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected overwrite protection error, got nil")
}
if !strings.Contains(err.Error(), "already exists") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) {
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "exists.txt")
if err := os.WriteFile(target, []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error: %v", err)
}
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir() error: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
_, err = saveContentToOutputDir(".", "exists.txt", []byte("new"), false)
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("expected overwrite error, got %v", err)
}
}
func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_export",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 2,
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "export",
"--ticket", "tk_export",
"--file-token", "docx123",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": false`)) {
t.Fatalf("stdout missing failed=false: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"job_status_label": "processing"`)) {
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
}
}

View File

@@ -0,0 +1,229 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// DriveImport uploads a local file, creates an import task, and polls until
// the imported cloud document is ready or the local polling window expires.
var DriveImport = common.Shortcut{
Service: "drive",
Command: "+import",
Description: "Import a local file to Drive as a cloud document (docx, sheet, bitable)",
Risk: "write",
Scopes: []string{
"docs:document.media:upload",
"docs:document:import",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md; large files auto use multipart upload)", Required: true},
{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveImportSpec(driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
}
fileSize, err := preflightDriveImportFile(&spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
}
if _, err := preflightDriveImportFile(&spec); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if status.URL != "" {
out["url"] = status.URL
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
runtime.Out(out, nil)
return nil
},
}
func preflightDriveImportFile(spec *driveImportSpec) (int64, error) {
// Keep dry-run and execution aligned on path normalization, file existence,
// and format-specific size limits before planning the upload path.
safeFilePath, err := validate.SafeInputPath(spec.FilePath)
if err != nil {
return 0, output.ErrValidation("unsafe file path: %s", err)
}
spec.FilePath = safeFilePath
info, err := os.Stat(spec.FilePath)
if err != nil {
return 0, output.ErrValidation("cannot read file: %s", err)
}
if !info.Mode().IsRegular() {
return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath)
}
if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil {
return 0, err
}
return info.Size(), nil
}
func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec, fileSize int64) {
extra, err := buildImportMediaExtra(spec.FilePath, spec.DocType)
if err != nil {
extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension())
}
if fileSize > maxDriveUploadFileSize {
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
Desc("[1a] Initialize multipart upload").
Body(map[string]interface{}{
"file_name": spec.SourceFileName(),
"parent_type": "ccm_import_open",
"parent_node": "",
"size": "<file_size>",
"extra": extra,
})
dry.POST("/open-apis/drive/v1/medias/upload_part").
Desc("[1b] Upload file parts (repeated)").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
})
dry.POST("/open-apis/drive/v1/medias/upload_finish").
Desc("[1c] Finalize multipart upload and get file_token").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return
}
dry.POST("/open-apis/drive/v1/medias/upload_all").
Desc("[1] Upload file to get file_token").
Body(map[string]interface{}{
"file_name": spec.SourceFileName(),
"parent_type": "ccm_import_open",
"size": "<file_size>",
"extra": extra,
"file": "@" + spec.FilePath,
})
}
// importTargetFileName returns the explicit import name when present, otherwise
// derives one from the local file name.
func importTargetFileName(filePath, explicitName string) string {
if explicitName != "" {
return explicitName
}
return importDefaultFileName(filePath)
}
// importDefaultFileName strips only the last extension so names like
// "report.final.csv" become "report.final".
func importDefaultFileName(filePath string) string {
base := filepath.Base(filePath)
ext := filepath.Ext(base)
if ext == "" {
return base
}
name := strings.TrimSuffix(base, ext)
if name == "" {
return base
}
return name
}

View File

@@ -0,0 +1,551 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
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"
)
var (
driveImportPollAttempts = 30
driveImportPollInterval = 2 * time.Second
)
const (
// These limits follow the current product-side import constraints per format.
driveImport20MBFileSizeLimit int64 = 20 * 1024 * 1024
driveImport100MBFileSizeLimit int64 = 100 * 1024 * 1024
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
)
type driveMultipartUploadSession struct {
UploadID string
BlockSize int
BlockNum int
}
// driveImportExtToDocTypes defines which source file extensions can be imported
// into which Drive-native document types.
var driveImportExtToDocTypes = map[string][]string{
"docx": {"docx"},
"doc": {"docx"},
"txt": {"docx"},
"md": {"docx"},
"mark": {"docx"},
"markdown": {"docx"},
"html": {"docx"},
"xlsx": {"sheet", "bitable"},
"xls": {"sheet"},
"csv": {"sheet", "bitable"},
}
// driveImportSpec contains the user-facing import inputs after normalization.
type driveImportSpec struct {
FilePath string
DocType string
FolderToken string
Name string
}
func (s driveImportSpec) FileExtension() string {
return strings.TrimPrefix(strings.ToLower(filepath.Ext(s.FilePath)), ".")
}
func (s driveImportSpec) SourceFileName() string {
return filepath.Base(s.FilePath)
}
func (s driveImportSpec) TargetFileName() string {
return importTargetFileName(s.FilePath, s.Name)
}
// CreateTaskBody builds the request body expected by /drive/v1/import_tasks.
func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} {
return map[string]interface{}{
"file_extension": s.FileExtension(),
"file_token": fileToken,
"type": s.DocType,
"file_name": s.TargetFileName(),
"point": map[string]interface{}{
"mount_type": 1,
// The import API treats an empty mount_key as "use the caller's root
// folder", so preserve the zero value when --folder-token is omitted.
"mount_key": s.FolderToken,
},
}
}
// uploadMediaForImport uploads the source file to the temporary import media
// endpoint and returns the file token consumed by import_tasks.
func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) {
importInfo, err := os.Stat(filePath)
if err != nil {
return "", output.ErrValidation("cannot read file: %s", err)
}
fileSize := importInfo.Size()
if err = validateDriveImportFileSize(filePath, docType, fileSize); err != nil {
return "", err
}
fileSizeValue, err := driveUploadSizeValue(fileSize)
if err != nil {
return "", err
}
extra, err := buildImportMediaExtra(filePath, docType)
if err != nil {
return "", err
}
if fileSize <= maxDriveUploadFileSize {
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize))
return uploadMediaForImportAll(runtime, filePath, fileName, fileSizeValue, extra)
}
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import via multipart upload: %s (%s)\n", fileName, common.FormatSize(fileSize))
return uploadMediaForImportMultipart(runtime, filePath, fileName, fileSizeValue, extra)
}
func uploadMediaForImportAll(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", output.ErrValidation("cannot read file: %s", err)
}
defer f.Close()
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", "ccm_import_open")
fd.AddField("size", fmt.Sprintf("%d", fileSize))
fd.AddField("extra", extra)
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", wrapDriveUploadRequestError(err, "upload media failed")
}
data, err := parseDriveUploadResponse(apiResp, "upload media failed")
if err != nil {
return "", err
}
return extractDriveUploadFileToken(data, "upload media failed")
}
func uploadMediaForImportMultipart(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) {
session, err := prepareMediaImportUpload(runtime, fileName, fileSize, extra)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload prepare failed: %s\n", err)
return "", err
}
totalBlocks := session.BlockNum
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", totalBlocks, common.FormatSize(int64(session.BlockSize)))
f, err := os.Open(filePath)
if err != nil {
return "", output.ErrValidation("cannot read file: %s", err)
}
defer f.Close()
buffer := make([]byte, session.BlockSize)
remaining := fileSize
uploadedBlocks := 0
for remaining > 0 {
chunkSize := session.BlockSize
if chunkSize > remaining {
chunkSize = remaining
}
n, readErr := io.ReadFull(f, buffer[:chunkSize])
if readErr != nil {
return "", output.ErrValidation("cannot read file: %s", readErr)
}
if err = uploadMediaImportPart(runtime, session.UploadID, uploadedBlocks, buffer[:n]); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload part failed: %s\n", err)
return "", err
}
remaining -= n
uploadedBlocks++
}
if session.BlockNum > 0 && session.BlockNum != uploadedBlocks {
return "", output.Errorf(output.ExitAPI, "api_error", "upload prepare mismatch: expected %d blocks, uploaded %d", session.BlockNum, uploadedBlocks)
}
return finishMediaImportUpload(runtime, session.UploadID, uploadedBlocks)
}
func prepareMediaImportUpload(runtime *common.RuntimeContext, fileName string, fileSize int, extra string) (driveMultipartUploadSession, error) {
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, map[string]interface{}{
"file_name": fileName,
"parent_type": "ccm_import_open", // For media import uploads, parent_type must be ccm_import_open.
"size": fileSize,
"extra": extra,
"parent_node": "", // For media import uploads, parent_node must be an explicit empty string; unlike medias/upload_all, this field cannot be omitted.
})
if err != nil {
return driveMultipartUploadSession{}, err
}
session := driveMultipartUploadSession{
UploadID: common.GetString(data, "upload_id"),
BlockSize: int(common.GetFloat(data, "block_size")),
BlockNum: int(common.GetFloat(data, "block_num")),
}
if session.UploadID == "" {
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: no upload_id returned")
}
if session.BlockSize <= 0 {
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
}
if session.BlockNum <= 0 {
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_num returned")
}
return session, nil
}
func uploadMediaImportPart(runtime *common.RuntimeContext, uploadID string, seq int, chunk []byte) error {
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", seq)
fd.AddField("size", len(chunk))
fd.AddFile("file", bytes.NewReader(chunk))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return wrapDriveUploadRequestError(err, "upload media part failed")
}
_, err = parseDriveUploadResponse(apiResp, "upload media part failed")
return err
}
func finishMediaImportUpload(runtime *common.RuntimeContext, uploadID string, blockNum int) (string, error) {
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload finish failed: %s\n", err)
return "", err
}
return extractDriveUploadFileToken(data, "upload media finish failed")
}
func buildImportMediaExtra(filePath, docType string) (string, error) {
extraBytes, err := json.Marshal(map[string]string{
"obj_type": docType,
"file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."),
})
if err != nil {
return "", output.Errorf(output.ExitInternal, "json_error", "build upload extra failed: %v", err)
}
return string(extraBytes), nil
}
func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
// Keep the limit mapping local to import flows so we do not widen behavior
// changes beyond drive +import.
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
case "docx", "doc":
return driveImport600MBFileSizeLimit, true
case "txt", "md", "mark", "markdown", "html", "xls":
return driveImport20MBFileSizeLimit, true
case "xlsx":
return driveImport800MBFileSizeLimit, true
case "csv":
if docType == "bitable" {
return driveImport100MBFileSizeLimit, true
}
return driveImport20MBFileSizeLimit, true
default:
return 0, false
}
}
func validateDriveImportFileSize(filePath, docType string, fileSize int64) error {
limit, ok := driveImportFileSizeLimit(filePath, docType)
if !ok || fileSize <= limit {
return nil
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
if ext == "csv" {
// CSV is the only source format whose limit depends on the target type.
return output.ErrValidation(
"file %s exceeds %s import limit for .csv when importing as %s",
common.FormatSize(fileSize),
common.FormatSize(limit),
docType,
)
}
return output.ErrValidation(
"file %s exceeds %s import limit for .%s",
common.FormatSize(fileSize),
common.FormatSize(limit),
ext,
)
}
func driveUploadSizeValue(fileSize int64) (int, error) {
maxInt := int64(^uint(0) >> 1)
if fileSize > maxInt {
return 0, output.ErrValidation("file %s is too large to upload", common.FormatSize(fileSize))
}
return int(fileSize), nil
}
func wrapDriveUploadRequestError(err error, action string) error {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.ErrNetwork("%s: %v", action, err)
}
func parseDriveUploadResponse(apiResp *larkcore.ApiResp, action string) (map[string]interface{}, error) {
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: invalid response JSON: %v", action, err)
}
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
return data, nil
}
func extractDriveUploadFileToken(data map[string]interface{}, action string) (string, error) {
fileToken := common.GetString(data, "file_token")
if fileToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "%s: no file_token returned", action)
}
return fileToken, nil
}
// validateDriveImportSpec enforces the CLI-level compatibility rules before any
// upload or import request is sent to the backend.
func validateDriveImportSpec(spec driveImportSpec) error {
ext := spec.FileExtension()
if ext == "" {
return output.ErrValidation("file must have an extension (e.g. .md, .docx, .xlsx)")
}
switch spec.DocType {
case "docx", "sheet", "bitable":
default:
return output.ErrValidation("unsupported target document type: %s. Supported types are: docx, sheet, bitable", spec.DocType)
}
supportedTypes, ok := driveImportExtToDocTypes[ext]
if !ok {
return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv", ext)
}
typeAllowed := false
// Validate the extension/type pair locally so users get a precise error
// before the file upload step.
for _, allowedType := range supportedTypes {
if allowedType == spec.DocType {
typeAllowed = true
break
}
}
if !typeAllowed {
var hint string
switch ext {
case "xlsx", "csv":
hint = fmt.Sprintf(".%s files can only be imported as 'sheet' or 'bitable', not '%s'", ext, spec.DocType)
case "xls":
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
default:
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
}
return output.ErrValidation("file type mismatch: %s", hint)
}
if strings.TrimSpace(spec.FolderToken) != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
return nil
}
// driveImportStatus captures the backend fields needed to decide whether the
// import can be surfaced immediately or requires a follow-up poll.
type driveImportStatus struct {
Ticket string
DocType string
Token string
URL string
JobErrorMsg string
Extra interface{}
JobStatus int
}
func (s driveImportStatus) Ready() bool {
return s.Token != "" && s.JobStatus == 0
}
func (s driveImportStatus) Pending() bool {
return s.JobStatus == 1 || s.JobStatus == 2 || (s.JobStatus == 0 && s.Token == "")
}
func (s driveImportStatus) Failed() bool {
return !s.Ready() && !s.Pending() && s.JobStatus != 0
}
func (s driveImportStatus) StatusLabel() string {
switch s.JobStatus {
case 0:
// Some responses report status=0 before the imported token is materialized.
// Treat that intermediate state as pending rather than completed.
if s.Token == "" {
return "pending"
}
return "success"
case 1:
return "new"
case 2:
return "processing"
default:
return fmt.Sprintf("status_%d", s.JobStatus)
}
}
// driveImportTaskResultCommand prints the resume command returned after bounded
// polling times out locally.
func driveImportTaskResultCommand(ticket string) string {
return fmt.Sprintf("lark-cli drive +task_result --scenario import --ticket %s", ticket)
}
// createDriveImportTask creates the server-side import task after the media
// upload has produced a reusable file token.
func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec, fileToken string) (string, error) {
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken))
if err != nil {
return "", err
}
ticket := common.GetString(data, "ticket")
if ticket == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "no ticket returned from import_tasks")
}
return ticket, nil
}
// getDriveImportStatus fetches the current state of an import task by ticket.
func getDriveImportStatus(runtime *common.RuntimeContext, ticket string) (driveImportStatus, error) {
if err := validate.ResourceName(ticket, "--ticket"); err != nil {
return driveImportStatus{}, output.ErrValidation("%s", err)
}
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/drive/v1/import_tasks/%s", validate.EncodePathSegment(ticket)),
nil,
nil,
)
if err != nil {
return driveImportStatus{}, err
}
return parseDriveImportStatus(ticket, data), nil
}
// parseDriveImportStatus accepts either the wrapped API response or an already
// extracted result object to keep the helper easy to test.
func parseDriveImportStatus(ticket string, data map[string]interface{}) driveImportStatus {
result := common.GetMap(data, "result")
if result == nil {
// Some tests and helper call sites already pass the unwrapped result body.
result = data
}
return driveImportStatus{
Ticket: ticket,
DocType: common.GetString(result, "type"),
Token: common.GetString(result, "token"),
URL: common.GetString(result, "url"),
JobErrorMsg: common.GetString(result, "job_error_msg"),
Extra: result["extra"],
JobStatus: int(common.GetFloat(result, "job_status")),
}
}
// pollDriveImportTask waits for the import to finish within a bounded window
// and returns the last observed status for resume-on-timeout flows.
func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveImportStatus, bool, error) {
lastStatus := driveImportStatus{Ticket: ticket}
var lastErr error
hadSuccessfulPoll := false
for attempt := 1; attempt <= driveImportPollAttempts; attempt++ {
if attempt > 1 {
time.Sleep(driveImportPollInterval)
}
status, err := getDriveImportStatus(runtime, ticket)
if err != nil {
lastErr = err
// Log the error but continue polling.
fmt.Fprintf(runtime.IO().ErrOut, "Import status attempt %d/%d failed: %v\n", attempt, driveImportPollAttempts, err)
continue
}
lastStatus = status
hadSuccessfulPoll = true
// Stop immediately on terminal states and otherwise return the last known
// status so the caller can expose a follow-up command on timeout.
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Import completed successfully.\n")
return status, true, nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return status, false, output.Errorf(output.ExitAPI, "api_error", "import failed with status %d: %s", status.JobStatus, msg)
}
}
if !hadSuccessfulPoll && lastErr != nil {
return lastStatus, false, lastErr
}
return lastStatus, false, nil
}

View File

@@ -0,0 +1,639 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime"
"mime/multipart"
"os"
"strings"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestValidateDriveImportSpecRejectsMismatchedType(t *testing.T) {
t.Parallel()
err := validateDriveImportSpec(driveImportSpec{
FilePath: "./data.xlsx",
DocType: "docx",
})
if err == nil || !strings.Contains(err.Error(), "file type mismatch") {
t.Fatalf("expected file type mismatch error, got %v", err)
}
}
func TestValidateDriveImportSpecRejectsXlsBitable(t *testing.T) {
t.Parallel()
err := validateDriveImportSpec(driveImportSpec{
FilePath: "./data.xls",
DocType: "bitable",
})
if err == nil || !strings.Contains(err.Error(), ".xls files can only be imported as 'sheet'") {
t.Fatalf("expected xls-only-sheet validation error, got %v", err)
}
}
func TestValidateDriveImportFileSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
filePath string
docType string
fileSize int64
wantText string
}{
{
name: "docx exceeds 600mb limit",
filePath: "./report.docx",
docType: "docx",
fileSize: driveImport600MBFileSizeLimit + 1,
wantText: "exceeds 600.0 MB import limit for .docx",
},
{
name: "csv sheet exceeds 20mb limit",
filePath: "./data.csv",
docType: "sheet",
fileSize: driveImport20MBFileSizeLimit + 1,
wantText: "exceeds 20.0 MB import limit for .csv when importing as sheet",
},
{
name: "csv bitable exceeds 100mb limit",
filePath: "./data.csv",
docType: "bitable",
fileSize: driveImport100MBFileSizeLimit + 1,
wantText: "exceeds 100.0 MB import limit for .csv when importing as bitable",
},
{
name: "xlsx within 800mb limit",
filePath: "./data.xlsx",
docType: "sheet",
fileSize: driveImport800MBFileSizeLimit,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveImportFileSize(tt.filePath, tt.docType, tt.fileSize)
if tt.wantText == "" {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("error = %v, want substring %q", err, tt.wantText)
}
})
}
}
func TestParseDriveImportStatus(t *testing.T) {
t.Parallel()
status := parseDriveImportStatus("tk_123", map[string]interface{}{
"result": map[string]interface{}{
"type": "sheet",
"job_status": 0,
"job_error_msg": "",
"token": "sheet_123",
"url": "https://example.com/sheets/sheet_123",
"extra": []interface{}{"2000"},
},
})
if !status.Ready() {
t.Fatal("expected import status to be ready")
}
if status.StatusLabel() != "success" {
t.Fatalf("status label = %q, want %q", status.StatusLabel(), "success")
}
if status.Token != "sheet_123" {
t.Fatalf("token = %q, want %q", status.Token, "sheet_123")
}
}
func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
t.Parallel()
status := driveImportStatus{JobStatus: 0}
if status.Ready() {
t.Fatal("expected status without token to be not ready")
}
if !status.Pending() {
t.Fatal("expected status without token to be pending")
}
if got := status.StatusLabel(); got != "pending" {
t.Fatalf("StatusLabel() = %q, want %q", got, "pending")
}
}
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_import"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_import",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"type": "sheet",
"job_status": 2,
},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
prevAttempts, prevInterval := driveImportPollAttempts, driveImportPollInterval
driveImportPollAttempts, driveImportPollInterval = 1, 0
t.Cleanup(func() {
driveImportPollAttempts, driveImportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "data.xlsx",
"--type", "sheet",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) {
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario import --ticket tk_import"`)) {
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
}
}
func TestDriveImportUsesMultipartUploadForLargeFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
prepareStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
},
}
reg.Register(prepareStub)
partStubs := make([]*httpmock.Stub, 0, 6)
for i := 0; i < 6; i++ {
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
partStubs = append(partStubs, stub)
reg.Register(stub)
}
finishStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "file_123",
},
},
}
reg.Register(finishStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_import"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_import",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"type": "sheet",
"job_status": 0,
"token": "sheet_123",
"url": "https://example.com/sheets/sheet_123",
},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "large.xlsx",
"--type", "sheet",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"token": "sheet_123"`)) {
t.Fatalf("stdout missing imported token: %s", stdout.String())
}
prepareBody := decodeCapturedJSONBody(t, prepareStub)
if got, _ := prepareBody["parent_type"].(string); got != "ccm_import_open" {
t.Fatalf("prepare parent_type = %q, want %q", got, "ccm_import_open")
}
if got, _ := prepareBody["file_name"].(string); got != "large.xlsx" {
t.Fatalf("prepare file_name = %q, want %q", got, "large.xlsx")
}
if got, _ := prepareBody["size"].(float64); got != float64(maxDriveUploadFileSize+1) {
t.Fatalf("prepare size = %v, want %d", got, maxDriveUploadFileSize+1)
}
firstPart := decodeCapturedMultipartBody(t, partStubs[0])
if got := firstPart.Fields["upload_id"]; got != "upload_123" {
t.Fatalf("first part upload_id = %q, want %q", got, "upload_123")
}
if got := firstPart.Fields["seq"]; got != "0" {
t.Fatalf("first part seq = %q, want %q", got, "0")
}
if got := firstPart.Fields["size"]; got != "4194304" {
t.Fatalf("first part size = %q, want %q", got, "4194304")
}
if got := len(firstPart.Files["file"]); got != 4*1024*1024 {
t.Fatalf("first part file size = %d, want %d", got, 4*1024*1024)
}
lastPart := decodeCapturedMultipartBody(t, partStubs[len(partStubs)-1])
if got := lastPart.Fields["seq"]; got != "5" {
t.Fatalf("last part seq = %q, want %q", got, "5")
}
if got := lastPart.Fields["size"]; got != "1" {
t.Fatalf("last part size = %q, want %q", got, "1")
}
if got := len(lastPart.Files["file"]); got != 1 {
t.Fatalf("last part file size = %d, want %d", got, 1)
}
finishBody := decodeCapturedJSONBody(t, finishStub)
if got, _ := finishBody["upload_id"].(string); got != "upload_123" {
t.Fatalf("finish upload_id = %q, want %q", got, "upload_123")
}
if got, _ := finishBody["block_num"].(float64); got != 6 {
t.Fatalf("finish block_num = %v, want %d", got, 6)
}
}
func TestDriveImportMultipartPrepareValidatesResponseFields(t *testing.T) {
tests := []struct {
name string
data map[string]interface{}
wantText string
}{
{
name: "missing upload id",
data: map[string]interface{}{
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
wantText: "upload prepare failed: no upload_id returned",
},
{
name: "missing block size",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_num": 6,
},
wantText: "upload prepare failed: invalid block_size returned",
},
{
name: "missing block num",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
},
wantText: "upload prepare failed: invalid block_num returned",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": tt.data,
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "large.xlsx",
"--type", "sheet",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("error = %v, want substring %q", err, tt.wantText)
}
})
}
}
func TestDriveImportMultipartUploadPartAPIFailure(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 999,
"msg": "chunk rejected",
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "large.xlsx",
"--type", "sheet",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "upload media part failed: [999] chunk rejected") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDriveImportMultipartFinishRequiresFileToken(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "large.xlsx",
"--type", "sheet",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "upload media finish failed: no file_token returned") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
writeSizedDriveImportFile(t, "too-large.csv", driveImport100MBFileSizeLimit+1)
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "too-large.csv",
"--type", "bitable",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected size limit error, got nil")
}
if !strings.Contains(err.Error(), "exceeds 100.0 MB import limit for .csv when importing as bitable") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseDriveUploadResponseErrors(t *testing.T) {
t.Parallel()
t.Run("invalid json", func(t *testing.T) {
t.Parallel()
_, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte("{")}, "upload media failed")
if err == nil || !strings.Contains(err.Error(), "invalid response JSON") {
t.Fatalf("expected invalid JSON error, got %v", err)
}
})
t.Run("api code error", func(t *testing.T) {
t.Parallel()
_, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`)}, "upload media failed")
if err == nil || !strings.Contains(err.Error(), "upload media failed: [999] boom") {
t.Fatalf("expected API error, got %v", err)
}
})
}
func TestWrapDriveUploadRequestError(t *testing.T) {
t.Parallel()
t.Run("preserves exit error", func(t *testing.T) {
t.Parallel()
original := output.ErrValidation("bad input")
got := wrapDriveUploadRequestError(original, "upload media failed")
if got != original {
t.Fatalf("expected same exit error pointer, got %v", got)
}
})
t.Run("wraps generic error as network", func(t *testing.T) {
t.Parallel()
got := wrapDriveUploadRequestError(io.EOF, "upload media failed")
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected ExitError, got %T", got)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
}
if !strings.Contains(got.Error(), "upload media failed") {
t.Fatalf("unexpected error: %v", got)
}
})
}
type capturedMultipartBody struct {
Fields map[string]string
Files map[string][]byte
}
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode captured JSON body: %v", err)
}
return body
}
func writeSizedDriveImportFile(t *testing.T, name string, size int64) {
t.Helper()
fh, err := os.Create(name)
if err != nil {
t.Fatalf("Create(%q) error: %v", name, err)
}
if err := fh.Truncate(size); err != nil {
t.Fatalf("Truncate(%q) error: %v", name, err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close(%q) error: %v", name, err)
}
}
func decodeCapturedMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipartBody {
t.Helper()
contentType := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse multipart content type: %v", err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
body := capturedMultipartBody{
Fields: map[string]string{},
Files: map[string][]byte{},
}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read multipart part: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("read multipart data: %v", err)
}
if part.FileName() != "" {
body.Files[part.FormName()] = data
continue
}
body.Fields[part.FormName()] = string(data)
}
return body
}

View File

@@ -0,0 +1,363 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
func TestImportDefaultFileName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
filePath string
want string
}{
{
name: "strip xlsx extension",
filePath: "/tmp/base-import.xlsx",
want: "base-import",
},
{
name: "strip last extension only",
filePath: "/tmp/report.final.csv",
want: "report.final",
},
{
name: "keep name without extension",
filePath: "/tmp/README",
want: "README",
},
{
name: "keep hidden file name when trim would be empty",
filePath: "/tmp/.env",
want: ".env",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := importDefaultFileName(tt.filePath); got != tt.want {
t.Fatalf("importDefaultFileName(%q) = %q, want %q", tt.filePath, got, tt.want)
}
})
}
}
func TestImportTargetFileName(t *testing.T) {
t.Parallel()
if got := importTargetFileName("/tmp/base-import.xlsx", "custom-name.xlsx"); got != "custom-name.xlsx" {
t.Fatalf("explicit name should win, got %q", got)
}
if got := importTargetFileName("/tmp/base-import.xlsx", ""); got != "base-import" {
t.Fatalf("default import name = %q, want %q", got, "base-import")
}
}
func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("base-import.xlsx", []byte("fake-xlsx"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cmd := &cobra.Command{Use: "drive +import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
if err := cmd.Flags().Set("file", "./base-import.xlsx"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("type", "bitable"); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("folder-token", "fld_test"); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveImport.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
uploadName, _ := got.API[0].Body["file_name"].(string)
if uploadName != "base-import.xlsx" {
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
}
importName, _ := got.API[1].Body["file_name"].(string)
if importName != "base-import" {
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
}
}
func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
fh, err := os.Create("large.xlsx")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(int64(maxDriveUploadFileSize) + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
cmd := &cobra.Command{Use: "drive +import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
if err := cmd.Flags().Set("file", "./large.xlsx"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("type", "sheet"); err != nil {
t.Fatalf("set --type: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveImport.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 5 {
t.Fatalf("expected 5 API calls, got %d", len(got.API))
}
if got.API[0].URL != "/open-apis/drive/v1/medias/upload_prepare" {
t.Fatalf("dry-run first URL = %q, want upload_prepare", got.API[0].URL)
}
if got.API[1].URL != "/open-apis/drive/v1/medias/upload_part" {
t.Fatalf("dry-run second URL = %q, want upload_part", got.API[1].URL)
}
if got.API[2].URL != "/open-apis/drive/v1/medias/upload_finish" {
t.Fatalf("dry-run third URL = %q, want upload_finish", got.API[2].URL)
}
}
func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
if err := cmd.Flags().Set("file", "../outside.md"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("type", "docx"); err != nil {
t.Fatalf("set --type: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveImport.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct{} `json:"api"`
Error string `json:"error"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if got.Error == "" || !strings.Contains(got.Error, "unsafe file path") {
t.Fatalf("dry-run error = %q, want unsafe file path error", got.Error)
}
if len(got.API) != 0 {
t.Fatalf("expected no API calls when preflight fails, got %d", len(got.API))
}
}
func TestDriveImportDryRunReturnsErrorForOversizedMarkdown(t *testing.T) {
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
fh, err := os.Create("large.md")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(driveImport20MBFileSizeLimit + 5*1024*1024); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
cmd := &cobra.Command{Use: "drive +import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
if err := cmd.Flags().Set("file", "./large.md"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("type", "docx"); err != nil {
t.Fatalf("set --type: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveImport.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct{} `json:"api"`
Error string `json:"error"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if got.Error == "" || !strings.Contains(got.Error, "exceeds 20.0 MB import limit for .md") {
t.Fatalf("dry-run error = %q, want oversized markdown error", got.Error)
}
if len(got.API) != 0 {
t.Fatalf("expected no API calls when size preflight fails, got %d", len(got.API))
}
}
func TestDriveImportDryRunReturnsErrorForDirectoryInput(t *testing.T) {
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.Mkdir("folder-input", 0755); err != nil {
t.Fatalf("Mkdir() error: %v", err)
}
cmd := &cobra.Command{Use: "drive +import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
if err := cmd.Flags().Set("file", "./folder-input"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("type", "docx"); err != nil {
t.Fatalf("set --type: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveImport.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct{} `json:"api"`
Error string `json:"error"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if got.Error == "" || !strings.Contains(got.Error, "file must be a regular file") {
t.Fatalf("dry-run error = %q, want regular file error", got.Error)
}
if len(got.API) != 0 {
t.Fatalf("expected no API calls when file type preflight fails, got %d", len(got.API))
}
}
func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
t.Parallel()
spec := driveImportSpec{
FilePath: "/tmp/README.md",
DocType: "docx",
}
body := spec.CreateTaskBody("file_token_test")
point, ok := body["point"].(map[string]interface{})
if !ok {
t.Fatalf("point = %#v, want map", body["point"])
}
raw, exists := point["mount_key"]
if !exists {
t.Fatal("mount_key missing; want empty string for root import")
}
got, ok := raw.(string)
if !ok {
t.Fatalf("mount_key type = %T, want string", raw)
}
if got != "" {
t.Fatalf("mount_key = %q, want empty string for root import", got)
}
spec.FolderToken = "fld_test"
body = spec.CreateTaskBody("file_token_test")
point, ok = body["point"].(map[string]interface{})
if !ok {
t.Fatalf("point = %#v, want map", body["point"])
}
if got, _ := point["mount_key"].(string); got != "fld_test" {
t.Fatalf("mount_key = %q, want %q", got, "fld_test")
}
}

View File

@@ -5,9 +5,11 @@ package drive
import (
"bytes"
"fmt"
"net/http"
"os"
"strings"
"sync/atomic"
"testing"
"github.com/spf13/cobra"
@@ -18,9 +20,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
var driveTestConfigSeq atomic.Int64
func driveTestConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
AppID: fmt.Sprintf("drive-test-app-%d", driveTestConfigSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu,
}
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// DriveMove moves a Drive file or folder and handles the async task polling
// required by folder moves.
var DriveMove = common.Shortcut{
Service: "drive",
Command: "+move",
Description: "Move a file or folder to another location in Drive",
Risk: "write",
Scopes: []string{"space:document:move"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "file or folder token to move", Required: true},
{Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token (default: root folder)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveMoveSpec(driveMoveSpec{
FileToken: runtime.Str("file-token"),
FileType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveMoveSpec{
FileToken: runtime.Str("file-token"),
FileType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
}
dry := common.NewDryRunAPI().
Desc("Move file or folder in Drive")
dry.POST("/open-apis/drive/v1/files/:file_token/move").
Desc("[1] Move file/folder").
Set("file_token", spec.FileToken).
Body(spec.RequestBody())
// If moving a folder, show the async task check step
if spec.FileType == "folder" {
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[2] Poll async task status (for folder move)").
Params(driveTaskCheckParams("<task_id>"))
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveMoveSpec{
FileToken: runtime.Str("file-token"),
FileType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
}
// Default to the caller's root folder so the command can move items
// without requiring an explicit destination in common cases.
if spec.FolderToken == "" {
fmt.Fprintf(runtime.IO().ErrOut, "No target folder specified, getting root folder...\n")
rootToken, err := getRootFolderToken(ctx, runtime)
if err != nil {
return err
}
if rootToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "get root folder token failed, root folder is empty")
}
spec.FolderToken = rootToken
}
fmt.Fprintf(runtime.IO().ErrOut, "Moving %s %s to folder %s...\n", spec.FileType, common.MaskToken(spec.FileToken), common.MaskToken(spec.FolderToken))
data, err := runtime.CallAPI(
"POST",
fmt.Sprintf("/open-apis/drive/v1/files/%s/move", validate.EncodePathSegment(spec.FileToken)),
nil,
spec.RequestBody(),
)
if err != nil {
return err
}
// Folder moves are asynchronous; file moves complete in the initial call.
if spec.FileType == "folder" {
taskID := common.GetString(data, "task_id")
if taskID == "" {
return output.Errorf(output.ExitAPI, "api_error", "move folder returned no task_id")
}
fmt.Fprintf(runtime.IO().ErrOut, "Folder move is async, polling task %s...\n", taskID)
status, ready, err := pollDriveTaskCheck(runtime, taskID)
if err != nil {
return err
}
// Include both the source and destination identifiers so a timed-out
// folder move can be resumed or inspected without reconstructing inputs.
out := map[string]interface{}{
"task_id": taskID,
"status": status.StatusLabel(),
"file_token": spec.FileToken,
"folder_token": spec.FolderToken,
"ready": ready,
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID)
fmt.Fprintf(runtime.IO().ErrOut, "Folder move task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
runtime.Out(out, nil)
} else {
// Non-folder moves are synchronous, so the initial request is the final
// outcome and no follow-up task metadata is needed.
runtime.Out(map[string]interface{}{
"file_token": spec.FileToken,
"folder_token": spec.FolderToken,
"type": spec.FileType,
}, nil)
}
return nil
},
}
// getRootFolderToken resolves the caller's Drive root folder token so other
// commands can safely use it as a default destination.
func getRootFolderToken(ctx context.Context, runtime *common.RuntimeContext) (string, error) {
data, err := runtime.CallAPI("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil)
if err != nil {
return "", err
}
token := common.GetString(data, "token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "root_folder/meta returned no token")
}
return token, nil
}

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"fmt"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
driveMovePollAttempts = 30
driveMovePollInterval = 2 * time.Second
)
// driveMoveAllowedTypes mirrors the document kinds accepted by the Drive move
// endpoint that this shortcut wraps.
var driveMoveAllowedTypes = map[string]bool{
"file": true,
"docx": true,
"bitable": true,
"doc": true,
"sheet": true,
"mindnote": true,
"folder": true,
"slides": true,
}
// driveMoveSpec contains the normalized input needed to issue a move request.
type driveMoveSpec struct {
FileToken string
FileType string
FolderToken string
}
func (s driveMoveSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"type": s.FileType,
"folder_token": s.FolderToken,
}
}
func validateDriveMoveSpec(spec driveMoveSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if strings.TrimSpace(spec.FolderToken) != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
if !driveMoveAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType)
}
return nil
}
// driveTaskCheckStatus represents the status payload returned by
// /drive/v1/files/task_check for async folder operations.
type driveTaskCheckStatus struct {
TaskID string
Status string
}
func (s driveTaskCheckStatus) Ready() bool {
return strings.EqualFold(strings.TrimSpace(s.Status), "success")
}
func (s driveTaskCheckStatus) Failed() bool {
return strings.EqualFold(strings.TrimSpace(s.Status), "failed")
}
func (s driveTaskCheckStatus) Pending() bool {
return !s.Ready() && !s.Failed()
}
func (s driveTaskCheckStatus) StatusLabel() string {
status := strings.TrimSpace(s.Status)
if status == "" {
// Empty status is treated as unknown so callers can still render a
// meaningful label instead of an empty string.
return "unknown"
}
return status
}
// driveTaskCheckResultCommand prints the resume command shown when bounded
// polling ends before the backend task completes.
func driveTaskCheckResultCommand(taskID string) string {
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s", taskID)
}
// driveTaskCheckParams keeps the task_check query parameter shape in one place
// for both dry-run and execution paths.
func driveTaskCheckParams(taskID string) map[string]interface{} {
return map[string]interface{}{"task_id": taskID}
}
// getDriveTaskCheckStatus fetches and validates the current state of an async
// folder move or delete task.
func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return driveTaskCheckStatus{}, output.ErrValidation("%s", err)
}
data, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil)
if err != nil {
return driveTaskCheckStatus{}, err
}
return parseDriveTaskCheckStatus(taskID, data), nil
}
// parseDriveTaskCheckStatus tolerates both wrapped and already-unwrapped
// response shapes used in tests and helpers.
func parseDriveTaskCheckStatus(taskID string, data map[string]interface{}) driveTaskCheckStatus {
result := common.GetMap(data, "result")
if result == nil {
result = data
}
return driveTaskCheckStatus{
TaskID: taskID,
Status: common.GetString(result, "status"),
}
}
// pollDriveTaskCheck polls the backend for a bounded period and returns the
// last seen status so callers can emit a follow-up command when needed.
func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, bool, error) {
lastStatus := driveTaskCheckStatus{TaskID: taskID}
for attempt := 1; attempt <= driveMovePollAttempts; attempt++ {
if attempt > 1 {
time.Sleep(driveMovePollInterval)
}
status, err := getDriveTaskCheckStatus(runtime, taskID)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Error polling task %s: %s\n", taskID, err)
continue
}
lastStatus = status
// Success and failure are terminal backend states. Any other value is kept
// as pending so the caller can decide whether to continue or resume later.
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Folder move completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder move task failed")
}
}
return lastStatus, false, nil
}

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestParseDriveTaskCheckStatusFallback(t *testing.T) {
t.Parallel()
status := parseDriveTaskCheckStatus("task_123", map[string]interface{}{
"status": "success",
})
if !status.Ready() {
t.Fatal("expected task check status to be ready")
}
if status.StatusLabel() != "success" {
t.Fatalf("status label = %q, want %q", status.StatusLabel(), "success")
}
}
func TestDriveTaskCheckStatusPendingAndUnknownLabel(t *testing.T) {
t.Parallel()
status := driveTaskCheckStatus{}
if !status.Pending() {
t.Fatal("expected empty status to be treated as pending")
}
if got := status.StatusLabel(); got != "unknown" {
t.Fatalf("StatusLabel() = %q, want %q", got, "unknown")
}
}
func TestValidateDriveMoveSpecRejectsUnsupportedType(t *testing.T) {
t.Parallel()
err := validateDriveMoveSpec(driveMoveSpec{
FileToken: "file_token_test",
FileType: "unsupported_type",
})
if err == nil {
t.Fatal("expected unsupported type error, got nil")
}
if got := err.Error(); !bytes.Contains([]byte(got), []byte("unsupported file type")) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +move"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", "folder"); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("folder-token", "fld_dst"); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveMove.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
}
if got.API[1].Params["task_id"] != "<task_id>" {
t.Fatalf("task check params = %#v", got.API[1].Params)
}
}
func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/fld_src/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
})
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
driveMovePollAttempts, driveMovePollInterval = 1, 0
t.Cleanup(func() {
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveMove, []string{
"+move",
"--file-token", "fld_src",
"--type", "folder",
"--folder-token", "fld_dst",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_123"`)) {
t.Fatalf("stdout missing task id: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
t.Fatalf("stdout missing ready=true: %s", stdout.String())
}
}
func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/fld_src/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "pending"},
},
})
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
driveMovePollAttempts, driveMovePollInterval = 1, 0
t.Cleanup(func() {
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveMove, []string{
"+move",
"--file-token", "fld_src",
"--type", "folder",
"--folder-token", "fld_dst",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) {
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123"`)) {
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
}
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveMoveUsesRootFolderWhenFolderTokenMissing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"token": "folder_root_token_test",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/file_token_test/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveMove, []string{
"+move",
"--file-token", "file_token_test",
"--type", "file",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"folder_token": "folder_root_token_test"`) {
t.Fatalf("stdout missing resolved root folder token: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"file_token": "file_token_test"`) {
t.Fatalf("stdout missing file token: %s", stdout.String())
}
}
func TestDriveMoveRootFolderLookupRequiresToken(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveMove, []string{
"+move",
"--file-token", "file_token_test",
"--type", "file",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected missing root folder token error, got nil")
}
if !strings.Contains(err.Error(), "root_folder/meta returned no token") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -0,0 +1,190 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// DriveTaskResult exposes a unified read path for the async task types produced
// by Drive import, export, and folder move flows.
var DriveTaskResult = common.Shortcut{
Service: "drive",
Command: "+task_result",
Description: "Poll async task result for import, export, move, or delete operations",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
{Name: "task-id", Desc: "async task ID (for move/delete folder tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, or task_check", Required: true},
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
scenario := strings.ToLower(runtime.Str("scenario"))
validScenarios := map[string]bool{
"import": true,
"export": true,
"task_check": true,
}
if !validScenarios[scenario] {
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check", scenario)
}
// Validate required params based on scenario
switch scenario {
case "import", "export":
if runtime.Str("ticket") == "" {
return output.ErrValidation("--ticket is required for %s scenario", scenario)
}
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
return output.ErrValidation("%s", err)
}
case "task_check":
if runtime.Str("task-id") == "" {
return output.ErrValidation("--task-id is required for task_check scenario")
}
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
return output.ErrValidation("%s", err)
}
}
// For export scenario, file-token is required
if scenario == "export" && runtime.Str("file-token") == "" {
return output.ErrValidation("--file-token is required for export scenario")
}
if scenario == "export" {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
scenario := strings.ToLower(runtime.Str("scenario"))
ticket := runtime.Str("ticket")
taskID := runtime.Str("task-id")
fileToken := runtime.Str("file-token")
dry := common.NewDryRunAPI()
dry.Desc(fmt.Sprintf("Poll async task result for %s scenario", scenario))
switch scenario {
case "import":
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[1] Query import task result").
Set("ticket", ticket)
case "export":
dry.GET("/open-apis/drive/v1/export_tasks/:ticket").
Desc("[1] Query export task result").
Set("ticket", ticket).
Params(map[string]interface{}{"token": fileToken})
case "task_check":
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[1] Query move/delete folder task status").
Params(driveTaskCheckParams(taskID))
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
scenario := strings.ToLower(runtime.Str("scenario"))
ticket := runtime.Str("ticket")
taskID := runtime.Str("task-id")
fileToken := runtime.Str("file-token")
fmt.Fprintf(runtime.IO().ErrOut, "Querying %s task result...\n", scenario)
var result map[string]interface{}
var err error
// Each scenario maps to a different backend API, but this shortcut keeps
// the CLI surface uniform for resume-on-timeout workflows.
switch scenario {
case "import":
result, err = queryImportTask(runtime, ticket)
case "export":
result, err = queryExportTask(runtime, ticket, fileToken)
case "task_check":
result, err = queryTaskCheck(runtime, taskID)
}
if err != nil {
return err
}
runtime.Out(result, nil)
return nil
},
}
// queryImportTask returns a stable, shortcut-friendly view of the import task.
func queryImportTask(runtime *common.RuntimeContext, ticket string) (map[string]interface{}, error) {
status, err := getDriveImportStatus(runtime, ticket)
if err != nil {
return nil, err
}
return map[string]interface{}{
"scenario": "import",
"ticket": status.Ticket,
"type": status.DocType,
"ready": status.Ready(),
"failed": status.Failed(),
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
"job_error_msg": status.JobErrorMsg,
"token": status.Token,
"url": status.URL,
"extra": status.Extra,
}, nil
}
// queryExportTask returns the export task status together with download metadata
// once the backend has produced the exported file.
func queryExportTask(runtime *common.RuntimeContext, ticket, fileToken string) (map[string]interface{}, error) {
status, err := getDriveExportStatus(runtime, fileToken, ticket)
if err != nil {
return nil, err
}
return map[string]interface{}{
"scenario": "export",
"ticket": status.Ticket,
"ready": status.Ready(),
"failed": status.Failed(),
"file_extension": status.FileExtension,
"type": status.DocType,
"file_name": status.FileName,
"file_token": status.FileToken,
"file_size": status.FileSize,
"job_error_msg": status.JobErrorMsg,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}, nil
}
// queryTaskCheck returns the normalized status of a folder move/delete task.
func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
status, err := getDriveTaskCheckStatus(runtime, taskID)
if err != nil {
return nil, err
}
return map[string]interface{}{
"scenario": "task_check",
"task_id": status.TaskID,
"status": status.StatusLabel(),
"ready": status.Ready(),
"failed": status.Failed(),
}, nil
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
t.Parallel()
tests := []struct {
name string
flags map[string]string
wantErr string
}{
{
name: "unsupported scenario",
flags: map[string]string{
"scenario": "unknown",
},
wantErr: "unsupported scenario",
},
{
name: "import missing ticket",
flags: map[string]string{
"scenario": "import",
},
wantErr: "--ticket is required",
},
{
name: "export missing file token",
flags: map[string]string{
"scenario": "export",
"ticket": "ticket_export_test",
},
wantErr: "--file-token is required",
},
{
name: "task check missing task id",
flags: map[string]string{
"scenario": "task_check",
},
wantErr: "--task-id is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +task_result"}
cmd.Flags().String("scenario", "", "")
cmd.Flags().String("ticket", "", "")
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("file-token", "", "")
for key, value := range tt.flags {
if err := cmd.Flags().Set(key, value); err != nil {
t.Fatalf("set --%s: %v", key, err)
}
}
runtime := common.TestNewRuntimeContext(cmd, nil)
err := DriveTaskResult.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}
func TestDriveTaskResultDryRunExportIncludesTokenParam(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +task_result"}
cmd.Flags().String("scenario", "", "")
cmd.Flags().String("ticket", "", "")
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("file-token", "", "")
if err := cmd.Flags().Set("scenario", "export"); err != nil {
t.Fatalf("set --scenario: %v", err)
}
if err := cmd.Flags().Set("ticket", "tk_export"); err != nil {
t.Fatalf("set --ticket: %v", err)
}
if err := cmd.Flags().Set("file-token", "doc_123"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveTaskResult.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Params["token"] != "doc_123" {
t.Fatalf("export status params = %#v", got.API[0].Params)
}
}
func TestDriveTaskResultImportIncludesReadyFlags(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_import",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"type": "sheet",
"job_status": 2,
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "import",
"--ticket", "tk_import",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"job_status_label": "processing"`)) {
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
}
}
func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "pending"},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "task_check",
"--task-id", "task_123",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"status": "pending"`)) {
t.Fatalf("stdout missing pending status: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": false`)) {
t.Fatalf("stdout missing failed=false: %s", stdout.String())
}
}

View File

@@ -11,5 +11,10 @@ func Shortcuts() []common.Shortcut {
DriveUpload,
DriveDownload,
DriveAddComment,
DriveExport,
DriveExportDownload,
DriveImport,
DriveMove,
DriveTaskResult,
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import "testing"
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{
"+upload",
"+download",
"+add-comment",
"+export",
"+export-download",
"+import",
"+move",
"+task_result",
}
if len(got) != len(want) {
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))
}
seen := make(map[string]bool, len(got))
for _, shortcut := range got {
if seen[shortcut.Command] {
t.Fatalf("duplicate shortcut command: %s", shortcut.Command)
}
seen[shortcut.Command] = true
}
for _, command := range want {
if !seen[command] {
t.Fatalf("missing shortcut command %q in Shortcuts()", command)
}
}
}

View File

@@ -447,6 +447,17 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImMessagesSearch invalid page limit", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "incident",
"page-limit": "41",
}, nil)
err := ImMessagesSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--page-limit must be an integer between 1 and 40") {
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
}
})
t.Run("ImMessagesSearch invalid sender id", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"sender": "user_1",
@@ -479,6 +490,45 @@ func TestShortcutValidateBranches(t *testing.T) {
})
}
func TestMessagesSearchPaginationConfig(t *testing.T) {
t.Run("default single page", func(t *testing.T) {
runtime := newTestRuntimeContext(t, nil, nil)
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
if autoPaginate {
t.Fatal("messagesSearchPaginationConfig() autoPaginate = true, want false")
}
if pageLimit != messagesSearchDefaultPageLimit {
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want %d", pageLimit, messagesSearchDefaultPageLimit)
}
})
t.Run("page all uses max limit", func(t *testing.T) {
runtime := newTestRuntimeContext(t, nil, map[string]bool{
"page-all": true,
})
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
if !autoPaginate {
t.Fatal("messagesSearchPaginationConfig() autoPaginate = false, want true")
}
if pageLimit != messagesSearchMaxPageLimit {
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want %d", pageLimit, messagesSearchMaxPageLimit)
}
})
t.Run("explicit page limit enables auto pagination", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"page-limit": "3",
}, nil)
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
if !autoPaginate {
t.Fatal("messagesSearchPaginationConfig() autoPaginate = false, want true")
}
if pageLimit != 3 {
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want 3", pageLimit)
}
})
}
func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{

View File

@@ -101,6 +101,9 @@ func TestResolveMarkdownAsPost(t *testing.T) {
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
}
if strings.Contains(got, `<br>`) {
t.Fatalf("resolveMarkdownAsPost() = %q, want no literal <br>", got)
}
}
func TestValidateContentFlags(t *testing.T) {

View File

@@ -619,31 +619,22 @@ func readMp4Duration(f *os.File, fileSize int64) int64 {
// Steps:
// 1. Extract code blocks with placeholders to protect them
// 2. Downgrade headings: H1 → H4, H2~H6 → H5 (only when H1~H3 present)
// 3. Add <br> between consecutive headings
// 4. Add spacing around tables with <br>
// 5. Restore code blocks with <br> wrappers
// 6. Compress excess blank lines
// 7. Strip invalid image references (keep only img_xxx keys)
// 3. Normalize spacing between consecutive headings and tables with blank lines
// 4. Restore code blocks
// 5. Compress excess blank lines
// 6. Strip invalid image references (keep only img_xxx keys)
var (
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
reTableBefore = regexp.MustCompile(`\n\n((?:\|.+\|[^\S\n]*\n?)+)`)
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
reTableTxtPre = regexp.MustCompile(`(?m)^([^\n]+)\n\n(<br>)\n\n(\|)`)
reTableBoldPre = regexp.MustCompile(`(?m)^(\*\*.+)\n\n(<br>)\n\n(\|)`)
reTableTxtPost = regexp.MustCompile(`(?m)(\|[^\n]*\n)\n(<br>\n)([^\n]+)`)
reExcessNL = regexp.MustCompile(`\n{3,}`)
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
reExcessNL = regexp.MustCompile(`\n{3,}`)
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
)
func isTableSpacingProtectedLine(line string) bool {
return strings.HasPrefix(line, "#### ") || strings.HasPrefix(line, "##### ") || strings.HasPrefix(line, "**")
}
func optimizeMarkdownStyle(text string) string {
const mark = "___CB_"
var codeBlocks []string
@@ -659,29 +650,13 @@ func optimizeMarkdownStyle(text string) string {
r = reH1.ReplaceAllString(r, "#### $1")
}
r = reConsecH.ReplaceAllString(r, "$1\n<br>\n$2")
r = reConsecH.ReplaceAllString(r, "$1\n\n$2")
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
r = reTableBefore.ReplaceAllString(r, "\n\n<br>\n\n$1")
r = reTableAfter.ReplaceAllString(r, "$1\n<br>\n")
r = reTableTxtPre.ReplaceAllStringFunc(r, func(m string) string {
sub := reTableTxtPre.FindStringSubmatch(m)
if len(sub) != 4 || isTableSpacingProtectedLine(sub[1]) {
return m
}
return sub[1] + "\n" + sub[2] + "\n" + sub[3]
})
r = reTableBoldPre.ReplaceAllString(r, "$1\n$2\n\n$3")
r = reTableTxtPost.ReplaceAllStringFunc(r, func(m string) string {
sub := reTableTxtPost.FindStringSubmatch(m)
if len(sub) != 4 || isTableSpacingProtectedLine(sub[3]) {
return m
}
return sub[1] + sub[2] + sub[3]
})
r = reTableAfter.ReplaceAllString(r, "$1\n")
for i, block := range codeBlocks {
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), "\n<br>\n"+block+"\n<br>\n", 1)
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1)
}
r = reExcessNL.ReplaceAllString(r, "\n\n")
@@ -901,7 +876,7 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
fd.AddField("image_type", imageType)
fd.AddFile("image", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/images",
Body: fd,
@@ -947,7 +922,7 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
}
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
apiResp, err := runtime.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/files",
Body: fd,

View File

@@ -263,7 +263,7 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) {
}))
target := filepath.Join(t.TempDir(), "nested", "resource.bin")
size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target)
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
@@ -307,7 +307,7 @@ func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
}
}))
_, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", filepath.Join(t.TempDir(), "out.bin"))
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", filepath.Join(t.TempDir(), "out.bin"))
if err == nil || !strings.Contains(err.Error(), "HTTP 403: denied") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}

View File

@@ -282,7 +282,7 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
{
name: "heading downgrade H1 and H2",
input: "# Title\n## Section\ntext",
want: "#### Title\n<br>\n##### Section\ntext",
want: "#### Title\n\n##### Section\ntext",
},
{
name: "no downgrade when no H1-H3",
@@ -292,17 +292,17 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
{
name: "code block protected",
input: "# Title\n```\n# not a heading\n```\ntext",
want: "#### Title\n\n<br>\n```\n# not a heading\n```\n<br>\n\ntext",
want: "#### Title\n```\n# not a heading\n```\ntext",
},
{
name: "table spacing",
input: "text\n| A | B |\n| - | - |\n| 1 | 2 |\nafter",
want: "text\n<br>\n| A | B |\n| - | - |\n| 1 | 2 |\n<br>\nafter",
want: "text\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nafter",
},
{
name: "table spacing keeps heading separation",
input: "# Title\n| A | B |\n| - | - |\n| 1 | 2 |\n## Next",
want: "#### Title\n\n<br>\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n<br>\n##### Next",
want: "#### Title\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n##### Next",
},
{
name: "excess blank lines compressed",
@@ -483,7 +483,7 @@ func TestDownloadIMResourceToPathHTTPClientError(t *testing.T) {
},
}
_, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "img_123", "image", "out.bin")
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "img_123", "image", "out.bin")
if err == nil || !strings.Contains(err.Error(), "http client unavailable") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}

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