Compare commits

..

26 Commits

Author SHA1 Message Date
liangshuo-1
4aceae9bff chore(release): v1.0.27 (#796)
Change-Id: I4004437e7dbeb195ab1133a8f7c657f9b6f835fd
2026-05-09 20:35:55 +08:00
Agent Fitz ;-)
44ffa98b89 fix: Fix installation errors when PowerShell is disabled by Group Policy. (#789) 2026-05-09 16:54:51 +08:00
terry
f9792f056e docs: clarify task member id types in references (#777)
Change-Id: Icaf012238cd93eeb784014d807c12168faf0a202

Co-authored-by: tengchengwei <tengchengwei@bytedance.com>
2026-05-09 14:16:11 +08:00
mazhe-nerd
6e22a7e518 feat(config): add lark-channel as a bind source (#786) 2026-05-08 22:39:23 +08:00
liangshuo-1
29a98966a0 chore(release): v1.0.26 (#785)
Change-Id: I27dd5e9ad7dc083ab41821cfcfb12c69354fa2b0
2026-05-08 19:39:26 +08:00
zgz2048
a81d07ca4f fix: clean base error detail output (#783) 2026-05-08 18:13:44 +08:00
sammi-bytedance
e754b3bc1b feat(im): add message_app_link to IM message outputs (#668)
- Assemble applinks via net/url to ensure proper encoding
- Normalize message position values across more numeric types
- Avoid leaking null message_app_link; assemble when missing
- Update unit tests to assert URL semantics and cover edge cases

Change-Id: Ic473cb563c8a648c4f6677c32b25b9f371a0f84e
2026-05-08 16:06:48 +08:00
JackZhao10086
a6de8360f0 feat(auth): add scope hint for missing authorization errors (#776)
* feat(auth): add scope hint for missing authorization errors

* fix(auth): handle existing hints in missing scope error

* refactor(auth): centralize user authorization error detection

* fix(auth): handle nil error case in IsNeedUserAuthorizationError
2026-05-08 15:23:29 +08:00
xzcong0820
88d7ec8ee7 feat(lark-mail): add data integrity and write-confirmation rules (#749)
Adds a new top-level safety section "数据真实性与操作合规" to the
lark-mail skill via the canonical generation pipeline:

  - skill-template/domains/mail.md (source) — adds the section to the
    domain introduction file that gen-skills.py renders into SKILL.md.
  - skills/lark-mail/SKILL.md (regenerated product) — produced by
    `make gen-skills project=mail` from larksuite-cli-registry against
    the modified mail.md source.

Why both files: skills/lark-mail/SKILL.md is auto-generated from
skill-template/domains/mail.md + registry-conf/skill-meta.yaml +
output/from_meta/mail.json. Editing only SKILL.md would be reverted on
the next `make gen-skills` run because SKILL.md has no AUTO-GENERATED
markers and falls into the "no markers -> overwrite whole file" branch
in scripts/gen-skills.py.

The section adds 3 hard constraints on agent behavior:
  - empty result is a valid answer; do not fabricate IDs or placeholders
  - explicit action preview before destructive write operations
    (delete / trash / batch_trash / cancel_scheduled_send / rules.*)
  - reversible modifications (label / read state / folder move) are
    exempt from the preview requirement

Addresses recurring evaluation failures (c03/c04/c06/c09/c14/c19~c24/c40)
where the agent fabricated IDs or auto-executed destructive operations.
2026-05-08 12:13:40 +08:00
syh-cpdsss
90757887b2 whiteboard-update as "write" risk (#775)
Change-Id: Iacc4d349b44337813392d75f4f0ec67718074efc
2026-05-07 22:53:37 +08:00
liangshuo-1
88d4e3bd90 chore(release): v1.0.25 (#774)
Change-Id: I9713902d6d7fdfb399e59d8ae23009789a71be3d
2026-05-07 21:19:01 +08:00
MaxHuang22
7c68639b31 fix: remove misleading default value from --as flag help text (#769)
The --as flag displayed (default "bot"), (default "user"), or
(default "auto") in help text, but ResolveAs() never uses the cobra
default — it resolves identity via credential config and auto-detect.
The displayed default misled users into thinking a fixed identity was
used when --as was omitted.

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

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

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

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

Changes:

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

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

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

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

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

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

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

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

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

* fix: remove redundant error return in login scope handler

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

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

* docs: refine field update conversion rules

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

Change-Id: I668bf3d856baa6e35ed982a33c4bf4d03b924f4b

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

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

* fix(base): address batch record PR feedback

* docs(base): refine record skill routing

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

* refactor(base): share record selection normalization

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

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

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

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

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

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

Addresses Case 12 in the 踩坑列表 doc.

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

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

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

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

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

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

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

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

Two consequences:

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

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

This commit:

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

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

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

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

---------

Co-authored-by: fangshuyu <fangshuyu@bytedance.com>
2026-04-30 18:52:44 +08:00
215 changed files with 12432 additions and 4386 deletions

View File

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

View File

@@ -2,6 +2,71 @@
All notable changes to this project will be documented in this file.
## [v1.0.27] - 2026-05-09
### Features
- **config**: Add `lark-channel` as a bind source (#786)
### Bug Fixes
- **install**: Fix installation errors when PowerShell is disabled by Group Policy (#789)
### Documentation
- **task**: Clarify task member id types in references (#777)
## [v1.0.26] - 2026-05-08
### Features
- **im**: Add `message_app_link` to message outputs (#668)
- **auth**: Add scope hint for missing authorization errors (#776)
### Bug Fixes
- **base**: Clean error detail output (#783)
- **whiteboard**: Reclassify `+update` as `write` risk (#775)
### Documentation
- **mail**: Add data integrity and write-confirmation rules (#749)
## [v1.0.25] - 2026-05-07
### Features
- Add skills version drift notice and unify update flow (#723)
### Bug Fixes
- Remove misleading default value from `--as` flag help text (#769)
- Handle negative truncate lengths (#744)
- Reject invalid JSON pointer escapes (#741)
- Migrate task shortcut errors to structured `output.Errorf`/`ErrValidation` (#740)
### Documentation
- Clarify base `user_open_id` guidance (#763)
## [v1.0.24] - 2026-05-06
### Features
- **sheets**: Add sheet management shortcuts (#722)
- **base**: Support batch record get and delete (#630)
- **task**: Add upload task attachment shortcut (#736)
- **drive**: Pre-flight 10000-rune total cap for `+add-comment` `reply_elements` (#605)
### Bug Fixes
- **auth**: Handle missing scopes and device flow improvements (#752)
- Add url to markdown `+create` output (#753)
### Documentation
- Refine field update conversion guidance (#748)
## [v1.0.23] - 2026-04-30
### Features
@@ -579,6 +644,10 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27
[v1.0.26]: https://github.com/larksuite/cli/releases/tag/v1.0.26
[v1.0.25]: https://github.com/larksuite/cli/releases/tag/v1.0.25
[v1.0.24]: https://github.com/larksuite/cli/releases/tag/v1.0.24
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -109,6 +109,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
f.CurrentCommand = cmd
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))

View File

@@ -60,13 +60,35 @@ func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.
cmd := &cobra.Command{
Use: "bind",
Short: "Bind Agent config to a workspace (source / app-id / force)",
Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace.
Long: `Bind an AI Agent's (OpenClaw / Hermes / Lark Channel) Feishu credentials to a lark-cli workspace.
For AI agents: pass --source and --app-id to bind non-interactively.
Credentials are synced once; subsequent calls in the Agent's process
context automatically use the bound workspace.`,
Example: ` lark-cli config bind --source openclaw --app-id <id>
lark-cli config bind --source hermes`,
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME / LARK_CHANNEL); pass it only to override.
For AI agents — DO NOT bind without user confirmation. Binding may
overwrite an existing one and locks in an identity policy. Ask the user:
--identity bot-only bot only (safer default; no impersonation;
cannot access user resources like personal
calendar / mail / drive)
--identity user-default user identity allowed (impersonates the user;
needed for personal-resource access)
Default to bot-only if the user is unsure. Only run the command after
the user confirms both intent and identity preset.
If lark-cli is already bound and the user only wants to change identity
policy on the SAME app, use 'config strict-mode' — that's the policy
switch and does not require re-bind. Use 'config bind' only when the
underlying app itself changes.
Interactive terminal use: run with no flags to enter the TUI form.`,
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
lark-cli config bind --source hermes --identity user-default
lark-cli config bind --source lark-channel
# Interactive (terminal user) — TUI prompts for everything:
lark-cli config bind`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.langExplicit = cmd.Flags().Changed("lang")
if runF != nil {
@@ -76,7 +98,7 @@ context automatically use the bound workspace.`,
},
}
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes); auto-detected from env signals when omitted")
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes|lark-channel); auto-detected from env signals when omitted")
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
@@ -125,6 +147,7 @@ func configBindRun(opts *BindOptions) error {
return err
}
applyPreferences(appConfig, opts)
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
}
@@ -153,8 +176,8 @@ type existingBinding struct {
// fall back to a TUI prompt (TUI mode) or an error (flag mode).
func finalizeSource(opts *BindOptions) (string, error) {
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
if explicit != "" && explicit != "openclaw" && explicit != "hermes" {
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes", explicit)
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit)
}
var detected string
@@ -163,6 +186,8 @@ func finalizeSource(opts *BindOptions) (string, error) {
detected = "openclaw"
case core.WorkspaceHermes:
detected = "hermes"
case core.WorkspaceLarkChannel:
detected = "lark-channel"
}
// Explicit and env detection must agree when both are present. Reject
@@ -199,7 +224,7 @@ func finalizeSource(opts *BindOptions) (string, error) {
}
return "", output.ErrWithHint(output.ExitValidation, "bind",
"cannot determine Agent source: no --source flag and no Agent environment detected",
"pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat")
"pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context")
}
// reconcileExistingBinding reads any existing config at configPath and decides
@@ -308,6 +333,23 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
}
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
// flag-mode bind that lands on user-default. The bot-only → user-default
// escalation is already covered by warnIdentityEscalation (errors out before
// applyPreferences runs), and the TUI flow shows IdentityUserDefaultDesc
// during identity selection — so this fires specifically for the case those
// two miss: a fresh flag-mode bind that goes directly to user-default with
// no previous bot lock to escalate from. Without this, AI agents finish such
// a bind with only a "配置成功" message and never relay to the user that the
// AI can now act under their identity.
func noticeUserDefaultRisk(opts *BindOptions) {
if opts.IsTUI || opts.Identity != "user-default" {
return
}
msg := getBindMsg(opts.Lang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
}
// applyPreferences expands the chosen identity preset into the underlying
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
// profile's intent survives later changes to global strict-mode settings.
@@ -428,6 +470,8 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
source = "openclaw"
case core.WorkspaceHermes:
source = "hermes"
case core.WorkspaceLarkChannel:
source = "lark-channel"
default:
source = "openclaw" // default first option
}
@@ -435,6 +479,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
// Resolve actual paths for display
openclawPath := resolveOpenClawConfigPath()
hermesEnvPath := resolveHermesEnvPath()
larkChannelPath := resolveLarkChannelConfigPath()
form := huh.NewForm(
huh.NewGroup(
@@ -444,6 +489,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
Options(
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
huh.NewOption(fmt.Sprintf(msg.SourceLarkChannel, larkChannelPath), "lark-channel"),
).
Value(&source),
),

View File

@@ -12,10 +12,11 @@ package config
type bindMsg struct {
// Source selection.
// SelectSourceDesc format: brand.
SelectSource string
SelectSourceDesc string
SourceOpenClaw string // format: resolved config path.
SourceHermes string // format: resolved dotenv path.
SelectSource string
SelectSourceDesc string
SourceOpenClaw string // format: resolved config path.
SourceHermes string // format: resolved dotenv path.
SourceLarkChannel string // format: resolved config path.
// Account selection (OpenClaw multi-account).
// Format: source display name ("OpenClaw" | "Hermes"), brand.
@@ -86,10 +87,11 @@ type bindMsg struct {
}
var bindMsgZh = &bindMsg{
SelectSource: "你想在哪个 Agent 中使用 lark-cli?",
SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息并配置到 lark-cli 中",
SourceOpenClaw: "OpenClaw — 配置文件: %s",
SourceHermes: "Hermes — 配置文件: %s",
SelectSource: "你想在哪个 Agent 中使用 lark-cli?",
SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息并配置到 lark-cli 中",
SourceOpenClaw: "OpenClaw — 配置文件: %s",
SourceHermes: "Hermes — 配置文件: %s",
SourceLarkChannel: "Lark Channel — 配置文件: %s",
SelectAccount: "检测到 %s 中已配置多个%s应用请选择一个",
@@ -117,10 +119,11 @@ var bindMsgZh = &bindMsg{
}
var bindMsgEn = &bindMsg{
SelectSource: "Which Agent are you running?",
SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.",
SourceOpenClaw: "OpenClaw — config: %s",
SourceHermes: "Hermes — config: %s",
SelectSource: "Which Agent are you running?",
SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.",
SourceOpenClaw: "OpenClaw — config: %s",
SourceHermes: "Hermes — config: %s",
SourceLarkChannel: "Lark Channel — config: %s",
// Args order (source, brand) matches the Chinese template; %[N]s lets the
// English reading order differ while the caller passes args in one order.

View File

@@ -123,7 +123,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `invalid --source "invalid"; valid values: openclaw, hermes`,
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
})
}
@@ -141,21 +141,29 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "bind",
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
Hint: "pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat",
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
})
}
// clearAgentEnv removes all env vars that DetectWorkspaceFromEnv checks, so
// tests exercising the "no signals" path are not affected by whatever the
// host shell happens to have exported. t.Setenv restores them after the
// test returns.
// clearAgentEnv removes every env var that DetectWorkspaceFromEnv treats as
// an Agent signal, so tests exercising the "no signals" path stay isolated
// from whatever the host shell exported. Prefix-based instead of an explicit
// list — when DetectWorkspaceFromEnv gains a new OPENCLAW_* / HERMES_* signal,
// this helper does not need to be updated and tests do not silently misroute.
// t.Setenv restores the original values after the test returns.
func clearAgentEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"OPENCLAW_CLI", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH",
"HERMES_HOME", "HERMES_QUIET", "HERMES_EXEC_ASK", "HERMES_GATEWAY_TOKEN", "HERMES_SESSION_KEY",
} {
t.Setenv(k, "")
for _, kv := range os.Environ() {
idx := strings.IndexByte(kv, '=')
if idx < 0 {
continue
}
k := kv[:idx]
if strings.HasPrefix(k, "OPENCLAW_") ||
strings.HasPrefix(k, "HERMES_") ||
k == "LARK_CHANNEL" {
t.Setenv(k, "")
}
}
}
@@ -339,6 +347,191 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
})
}
// writeLarkChannelFixture writes a ~/.lark-channel/config.json under fakeHome
// and returns the config path. resolveLarkChannelConfigPath reads HOME via
// os.UserHomeDir, so callers must `t.Setenv("HOME", fakeHome)`.
func writeLarkChannelFixture(t *testing.T, fakeHome, body string) string {
t.Helper()
dir := filepath.Join(fakeHome, ".lark-channel")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
t.Fatalf("write: %v", err)
}
return path
}
// Happy-path: --source lark-channel reads ~/.lark-channel/config.json,
// writes the workspace config, emits a JSON envelope with workspace:
// "lark-channel" and brand from accounts.app.tenant.
func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
clearAgentEnv(t)
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_lc_main","secret":"lc_secret","tenant":"feishu"}}}`)
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
t.Fatalf("expected success, got error: %v", err)
}
envelope := map[string]any{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
if envelope["workspace"] != "lark-channel" {
t.Errorf("workspace = %v, want %q", envelope["workspace"], "lark-channel")
}
if envelope["app_id"] != "cli_lc_main" {
t.Errorf("app_id = %v, want %q", envelope["app_id"], "cli_lc_main")
}
// Brand is not in the stdout envelope — read it back from the persisted
// workspace config to verify accounts.app.tenant flowed through to the
// stored AppConfig.Brand field.
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("load workspace config: %v", err)
}
if len(multi.Apps) != 1 {
t.Fatalf("expected 1 app, got %d", len(multi.Apps))
}
if got := string(multi.Apps[0].Brand); got != "feishu" {
t.Errorf("Brand = %q, want %q", got, "feishu")
}
}
// tenant: "lark" should land as Brand("lark"), not normalized to "feishu".
func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clearAgentEnv(t)
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_lc_lark","secret":"s","tenant":"lark"}}}`)
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
t.Fatalf("expected success, got error: %v", err)
}
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("load workspace config: %v", err)
}
if got := string(multi.Apps[0].Brand); got != "lark" {
t.Errorf("Brand = %q, want %q (tenant: lark must flow through to AppConfig.Brand)", got, "lark")
}
}
// LARK_CHANNEL=1 alone (no --source) auto-detects to the lark-channel
// workspace, mirroring the OpenClaw/Hermes auto-detect flow.
func TestConfigBindRun_AutoDetect_LarkChannelFromEnv(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clearAgentEnv(t)
t.Setenv("LARK_CHANNEL", "1")
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_auto_lc","secret":"s","tenant":"feishu"}}}`)
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f}); err != nil {
t.Fatalf("expected success, got error: %v", err)
}
envelope := map[string]any{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
if envelope["workspace"] != "lark-channel" {
t.Errorf("workspace = %v, want %q (auto-detection should pick lark-channel from LARK_CHANNEL=1)", envelope["workspace"], "lark-channel")
}
}
// --source lark-channel while the env signals OpenClaw must fail loud, same
// rule as OpenClaw/Hermes mismatch (running in the wrong Agent context).
func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clearAgentEnv(t)
t.Setenv("OPENCLAW_HOME", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "bind",
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
})
}
// Missing config.json → typed error with a hint pointing at bridge setup.
func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clearAgentEnv(t)
fakeHome := t.TempDir() // empty — no .lark-channel/config.json
t.Setenv("HOME", fakeHome)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "lark-channel",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify lark-channel-bridge is installed and configured",
})
}
// Empty accounts.app.id → typed error pointing at bridge setup. Distinct
// from "missing file" so users know whether to install or to re-run setup.
func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clearAgentEnv(t)
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
configPath := writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"","secret":"","tenant":"feishu"}}}`)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "lark-channel",
Message: "accounts.app.id missing in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
})
}
// app.id present but app.secret missing → typed error at the Build step.
func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clearAgentEnv(t)
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
configPath := writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_no_secret","secret":"","tenant":"feishu"}}}`)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "lark-channel",
Message: "accounts.app.secret is empty in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
})
}
func TestConfigShowRun_WorkspaceField(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
@@ -377,16 +570,28 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
if err == nil {
t.Fatal("expected error for unbound workspace")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
// Should be a structured ConfigError suggesting config bind, not config init.
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
}
// Hint must point at config bind --help (NOT a ready-to-run bind command):
// AI must read the help and confirm identity preset with the user first.
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point at `config bind --help`; got %q", cfgErr.Hint)
}
if strings.Contains(cfgErr.Hint, "config init") {
t.Errorf("agent hint must not mention config init; got %q", cfgErr.Hint)
}
// Should suggest config bind, not config init
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: "openclaw context detected but lark-cli not bound to openclaw workspace",
Hint: "run: lark-cli config bind --source openclaw",
})
}
// ── Helper function tests (dotenv, brand, path resolution) ──

View File

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

View File

@@ -46,6 +46,8 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
return &openclawBinder{opts: opts, path: resolveOpenClawConfigPath()}, nil
case "hermes":
return &hermesBinder{opts: opts, path: resolveHermesEnvPath()}, nil
case "lark-channel":
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
default:
return nil, output.ErrValidation("unsupported source: %s", source)
}
@@ -270,6 +272,65 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
}, nil
}
// ──────────────────────────────────────────────────────────────
// larkChannelBinder
// ──────────────────────────────────────────────────────────────
type larkChannelBinder struct {
opts *BindOptions
path string
// Cached between ListCandidates and Build so we don't re-read the file.
cfg *binding.LarkChannelRoot
}
func (b *larkChannelBinder) Name() string { return "lark-channel" }
func (b *larkChannelBinder) ConfigPath() string { return b.path }
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
cfg, err := binding.ReadLarkChannelConfig(b.path)
if err != nil {
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
fmt.Sprintf("cannot read %s: %v", b.path, err),
"verify lark-channel-bridge is installed and configured")
}
if cfg.Accounts.App.ID == "" {
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
fmt.Sprintf("accounts.app.id missing in %s", b.path),
"run lark-channel-bridge's setup to populate the app credential")
}
b.cfg = cfg
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
}
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, output.Errorf(output.ExitInternal, "lark-channel",
"internal: Build called before ListCandidates")
}
if b.cfg.Accounts.App.ID != appID {
return nil, output.Errorf(output.ExitInternal, "lark-channel",
"internal: appID %q does not match config", appID)
}
if b.cfg.Accounts.App.Secret == "" {
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
"run lark-channel-bridge's setup to populate the app credential")
}
stored, err := core.ForStorage(appID, core.PlainSecret(b.cfg.Accounts.App.Secret), b.opts.Factory.Keychain)
if err != nil {
return nil, output.Errorf(output.ExitInternal, "lark-channel",
"keychain unavailable: %v", err)
}
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
}, nil
}
// ──────────────────────────────────────────────────────────────
// Source-specific helpers (path / dotenv / brand) — kept private to this package.
// Moved here from bind.go so bind.go can focus on orchestration.
@@ -283,6 +344,8 @@ func sourceDisplayName(source string) string {
return "OpenClaw"
case "hermes":
return "Hermes"
case "lark-channel":
return "Lark Channel"
default:
return source
}
@@ -316,6 +379,18 @@ func resolveHermesEnvPath() string {
return filepath.Join(hermesHome, ".env")
}
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes
// ~/.lark-channel/config.json with no env override — multi-instance is not
// a supported scenario today.
func resolveLarkChannelConfigPath() string {
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
}
return filepath.Join(home, ".lark-channel", "config.json")
}
// resolveOpenClawConfigPath resolves openclaw.json path using the same priority
// chain as OpenClaw's src/config/paths.ts:
// 1. OPENCLAW_CONFIG_PATH env → exact file path

View File

@@ -38,6 +38,7 @@ func (r *recordingConfigKeychain) Remove(service, account string) error {
}
func TestConfigInitCmd_FlagParsing(t *testing.T) {
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret123\n")
@@ -90,15 +91,15 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
if cfgErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
}
}
@@ -136,6 +137,7 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
}
func TestConfigInitCmd_LangFlag(t *testing.T) {
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ConfigInitOptions
@@ -157,6 +159,7 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
}
func TestConfigInitCmd_LangDefault(t *testing.T) {
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ConfigInitOptions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

175
cmd/error_auth_hint.go Normal file
View File

@@ -0,0 +1,175 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"fmt"
"strings"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// enrichMissingScopeError preserves the original need_user_authorization
// message and appends a scope hint when the current command declares the
// required scopes locally.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
}
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
return
}
exitErr.Detail.Hint += "\n" + scopeHint
}
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata.
func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string {
if f == nil || f.CurrentCommand == nil {
return nil
}
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = string(core.AsUser)
}
if identity != string(core.AsUser) && identity != string(core.AsBot) {
return nil
}
if scopes := resolveDeclaredShortcutScopes(f.CurrentCommand, identity); len(scopes) > 0 {
return scopes
}
return resolveDeclaredServiceMethodScopes(f.CurrentCommand, identity)
}
// resolveDeclaredShortcutScopes returns the scopes declared by a mounted
// shortcut command for the given identity.
func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string {
if cmd == nil || cmd.Parent() == nil || !strings.HasPrefix(cmd.Name(), "+") {
return nil
}
service := cmd.Parent().Name()
for _, sc := range shortcuts.AllShortcuts() {
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
continue
}
scopes := sc.ScopesForIdentity(identity)
if len(scopes) == 0 {
return nil
}
return append([]string(nil), scopes...)
}
return nil
}
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
// service/resource/method command from the embedded from_meta registry.
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
// Service-method scope lookup only applies to commands mounted as
// root -> service -> resource -> method. Non-resource/method commands
// intentionally return no scopes here so auth-hint enrichment does not
// change runtime semantics for other command shapes.
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
return nil
}
if strings.HasPrefix(cmd.Name(), "+") {
return nil
}
service := cmd.Parent().Parent().Name()
resource := cmd.Parent().Name()
method := cmd.Name()
spec := registry.LoadFromMeta(service)
if spec == nil {
return nil
}
resources, _ := spec["resources"].(map[string]interface{})
resMap, _ := resources[resource].(map[string]interface{})
if resMap == nil {
return nil
}
methods, _ := resMap["methods"].(map[string]interface{})
methodMap, _ := methods[method].(map[string]interface{})
if methodMap == nil {
return nil
}
return declaredScopesForMethod(methodMap, identity)
}
// declaredScopesForMethod returns all requiredScopes when present; otherwise it
// resolves the single recommended scope from the method's scopes list.
func declaredScopesForMethod(method map[string]interface{}, identity string) []string {
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
return interfaceStrings(requiredRaw)
}
rawScopes, _ := method["scopes"].([]interface{})
if len(rawScopes) == 0 {
return nil
}
recommended := registry.SelectRecommendedScope(rawScopes, identity)
if recommended == "" {
for _, raw := range rawScopes {
if scope, ok := raw.(string); ok && scope != "" {
recommended = scope
break
}
}
}
if recommended == "" {
return nil
}
return []string{recommended}
}
// interfaceStrings converts a []interface{} containing strings into a compact
// []string, skipping empty or non-string values.
func interfaceStrings(values []interface{}) []string {
scopes := make([]string, 0, len(values))
for _, value := range values {
scope, ok := value.(string)
if !ok || scope == "" {
continue
}
scopes = append(scopes, scope)
}
return scopes
}
// shortcutSupportsIdentity reports whether a shortcut supports the requested
// identity, applying the default user-only behavior when AuthTypes is empty.
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
authTypes := sc.AuthTypes
if len(authTypes) == 0 {
authTypes = []string{string(core.AsUser)}
}
for _, authType := range authTypes {
if authType == identity {
return true
}
}
return false
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,9 +11,12 @@ import (
"github.com/larksuite/cli/cmd/auth"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/schema"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/spf13/cobra"
)
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
@@ -188,6 +191,124 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
}
}
func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
var target registry.CommandEntry
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
target = entry
break
}
}
if target.Command == "" {
t.Fatal("failed to locate a calendar create command in local registry metadata")
}
parts := strings.Split(target.Command, " ")
if len(parts) != 2 {
t.Fatalf("expected resource/method command, got %q", target.Command)
}
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "calendar"}
resourceCmd := &cobra.Command{Use: parts[0]}
methodCmd := &cobra.Command{Use: parts[1]}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(resourceCmd)
resourceCmd.AddCommand(methodCmd)
f.CurrentCommand = methodCmd
exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{})
enrichMissingScopeError(f, exitErr)
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected exit code %d, got %d", output.ExitAPI, exitErr.Code)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
t.Fatalf("expected api_error detail, got %+v", exitErr.Detail)
}
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): calendar:calendar.event:create") {
t.Fatalf("expected scope guidance in hint, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
}
if exitErr.Detail.Detail != nil {
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
}
}
func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "docs"}
shortcutCmd := &cobra.Command{Use: "+create"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
enrichMissingScopeError(f, exitErr)
if exitErr.Code != output.ExitNetwork {
t.Fatalf("expected exit code %d, got %d", output.ExitNetwork, exitErr.Code)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Fatalf("expected network detail, got %+v", exitErr.Detail)
}
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): docx:document:create") {
t.Fatalf("expected shortcut scope hint, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
}
if exitErr.Detail.Detail != nil {
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
}
}
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "docs"}
shortcutCmd := &cobra.Command{Use: "+create"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
exitErr.Detail.Hint = "existing hint"
enrichMissingScopeError(f, exitErr)
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
if exitErr.Detail.Hint != want {
t.Fatalf("expected appended hint %q, got %q", want, exitErr.Detail.Hint)
}
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,9 @@
package auth
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
)
@@ -12,6 +14,7 @@ import (
const (
LarkErrBlockByPolicy = 21001 // access denied by access control policy
LarkErrBlockByPolicyTryAuth = 21000 // access denied by access control policy; challenge is required to be completed by user in order to gain access
needUserAuthorizationMarker = "need_user_authorization"
)
// RefreshTokenRetryable contains error codes that allow one immediate retry.
@@ -33,7 +36,26 @@ type NeedAuthorizationError struct {
// Error returns the error message for NeedAuthorizationError.
func (e *NeedAuthorizationError) Error() string {
return fmt.Sprintf("need_user_authorization (user: %s)", e.UserOpenId)
return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId)
}
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
// failure, either as the original auth error or as a wrapped ExitError.
func IsNeedUserAuthorizationError(err error) bool {
if err == nil {
return false
}
var needAuthErr *NeedAuthorizationError
if errors.As(err, &needAuthErr) {
return true
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker)
}
return strings.Contains(err.Error(), needUserAuthorizationMarker)
}
// SecurityPolicyError is returned when a request is blocked by access control policies.

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"github.com/larksuite/cli/internal/output"
)
func TestIsNeedUserAuthorizationError(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
if IsNeedUserAuthorizationError(nil) {
t.Fatal("expected nil error not to match")
}
})
t.Run("direct auth error", func(t *testing.T) {
if !IsNeedUserAuthorizationError(&NeedAuthorizationError{UserOpenId: "u_1"}) {
t.Fatal("expected direct NeedAuthorizationError to match")
}
})
t.Run("wrapped exit error", func(t *testing.T) {
err := output.ErrNetwork("API call failed: %s", &NeedAuthorizationError{})
if !IsNeedUserAuthorizationError(err) {
t.Fatal("expected wrapped ExitError to match")
}
})
t.Run("other error", func(t *testing.T) {
err := output.ErrNetwork("API call failed: timeout")
if IsNeedUserAuthorizationError(err) {
t.Fatal("expected unrelated error not to match")
}
})
}

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// LarkChannelRoot captures ~/.lark-channel/config.json.
// Schema mirrors lark-channel-bridge/src/config/schema.ts:AppConfig.
// Unknown fields are ignored — forward-compatible with future bridge versions.
type LarkChannelRoot struct {
Accounts LarkChannelAccounts `json:"accounts"`
}
// LarkChannelAccounts is the namespace for credential entries.
// Currently only `app` is defined; left as a struct (not a flat field) so
// future entries (oauth, alternate apps) can be added without re-shaping the
// top-level on disk.
type LarkChannelAccounts struct {
App LarkChannelApp `json:"app"`
}
// LarkChannelApp is the bot app credential entry.
// Bridge stores the secret as plain text — secret-resolve indirection
// (${VAR} / file: / exec:) is intentionally not supported here, matching
// the bridge's on-disk format.
type LarkChannelApp struct {
ID string `json:"id"`
Secret string `json:"secret"`
Tenant string `json:"tenant"` // "feishu" | "lark"
}
// ReadLarkChannelConfig reads and parses ~/.lark-channel/config.json.
func ReadLarkChannelConfig(path string) (*LarkChannelRoot, error) {
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err // caller formats user-facing message with path context
}
var root LarkChannelRoot
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("invalid JSON in %s: %w", path, err)
}
return &root, nil
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"path/filepath"
"testing"
)
func TestReadLarkChannelConfig_Valid(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{"accounts":{"app":{"id":"cli_abc123","secret":"plain_secret","tenant":"feishu"}}}`
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadLarkChannelConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := root.Accounts.App.ID; got != "cli_abc123" {
t.Errorf("ID = %q, want %q", got, "cli_abc123")
}
if got := root.Accounts.App.Secret; got != "plain_secret" {
t.Errorf("Secret = %q, want %q", got, "plain_secret")
}
if got := root.Accounts.App.Tenant; got != "feishu" {
t.Errorf("Tenant = %q, want %q", got, "feishu")
}
}
func TestReadLarkChannelConfig_LarkTenant(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{"accounts":{"app":{"id":"cli_xyz","secret":"s","tenant":"lark"}}}`
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadLarkChannelConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := root.Accounts.App.Tenant; got != "lark" {
t.Errorf("Tenant = %q, want %q", got, "lark")
}
}
func TestReadLarkChannelConfig_MissingFile(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "does-not-exist.json")
_, err := ReadLarkChannelConfig(p)
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
if !os.IsNotExist(err) {
t.Errorf("expected os.IsNotExist, got %v", err)
}
}
func TestReadLarkChannelConfig_MalformedJSON(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
if err := os.WriteFile(p, []byte("{not valid json"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
_, err := ReadLarkChannelConfig(p)
if err == nil {
t.Fatal("expected error for malformed JSON, got nil")
}
}
func TestReadLarkChannelConfig_PartialFields(t *testing.T) {
// schema isComplete check belongs at the binder layer; the reader should
// happily parse a partial config — emptiness is detected downstream.
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{"accounts":{"app":{}}}`
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadLarkChannelConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if root.Accounts.App.ID != "" {
t.Errorf("expected empty ID, got %q", root.Accounts.App.ID)
}
if root.Accounts.App.Secret != "" {
t.Errorf("expected empty Secret, got %q", root.Accounts.App.Secret)
}
}
func TestReadLarkChannelConfig_UnknownFieldsIgnored(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{
"accounts": {
"app": {"id": "cli_a", "secret": "s", "tenant": "feishu"},
"oauth": {"clientId": "ignored"}
},
"preferences": {"theme": "dark"}
}`
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadLarkChannelConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := root.Accounts.App.ID; got != "cli_a" {
t.Errorf("ID = %q, want %q", got, "cli_a")
}
}

View File

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

View File

@@ -39,6 +39,7 @@ type Factory struct {
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
CurrentCommand *cobra.Command // last matched command being executed; set during PersistentPreRun
Credential *credential.CredentialProvider
@@ -160,10 +161,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
mode := f.ResolveStrictMode(ctx)
if mode.IsActive() && !mode.AllowsIdentity(as) {
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, only %s identity is allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode, mode.ForcedIdentity())
return output.ErrWithHint(output.ExitValidation, "strict_mode",
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ type Endpoints struct {
Open string // e.g. "https://open.feishu.cn"
Accounts string // e.g. "https://accounts.feishu.cn"
MCP string // e.g. "https://mcp.feishu.cn"
AppLink string // e.g. "https://applink.feishu.cn"
}
// ResolveEndpoints resolves endpoint URLs based on brand.
@@ -37,12 +38,14 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
Open: "https://open.larksuite.com",
Accounts: "https://accounts.larksuite.com",
MCP: "https://mcp.larksuite.com",
AppLink: "https://applink.larksuite.com",
}
default:
return Endpoints{
Open: "https://open.feishu.cn",
Accounts: "https://accounts.feishu.cn",
MCP: "https://mcp.feishu.cn",
AppLink: "https://applink.feishu.cn",
}
}
}

View File

@@ -16,6 +16,9 @@ func TestResolveEndpoints_Feishu(t *testing.T) {
if ep.MCP != "https://mcp.feishu.cn" {
t.Errorf("MCP = %q, want feishu.cn", ep.MCP)
}
if ep.AppLink != "https://applink.feishu.cn" {
t.Errorf("AppLink = %q, want feishu.cn", ep.AppLink)
}
}
func TestResolveEndpoints_Lark(t *testing.T) {
@@ -29,6 +32,9 @@ func TestResolveEndpoints_Lark(t *testing.T) {
if ep.MCP != "https://mcp.larksuite.com" {
t.Errorf("MCP = %q, want larksuite.com", ep.MCP)
}
if ep.AppLink != "https://applink.larksuite.com" {
t.Errorf("AppLink = %q, want larksuite.com", ep.AppLink)
}
}
func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {

View File

@@ -27,6 +27,11 @@ const (
// WorkspaceHermes activates when any Hermes-specific env signal is
// present (see DetectWorkspaceFromEnv for the full list).
WorkspaceHermes Workspace = "hermes"
// WorkspaceLarkChannel activates when LARK_CHANNEL == "1" is set by
// lark-channel-bridge in subprocesses it spawns (e.g. claude). See
// DetectWorkspaceFromEnv for the detection rule.
WorkspaceLarkChannel Workspace = "lark-channel"
)
// currentWorkspace holds the workspace for the current process invocation.
@@ -90,7 +95,10 @@ func (w Workspace) IsLocal() bool {
// - HERMES_EXEC_ASK == "1": exported by the gateway (paired w/ QUIET)
// - HERMES_GATEWAY_TOKEN: injected into every gateway subprocess
// - HERMES_SESSION_KEY: session identifier scoped to the current chat
// 3. Otherwise → WorkspaceLocal
// 3. LARK_CHANNEL == "1" → WorkspaceLarkChannel. Set by lark-channel-bridge
// when spawning subprocesses (e.g. claude). Single boolean marker —
// mirrors the OPENCLAW_CLI / HERMES_QUIET style.
// 4. Otherwise → WorkspaceLocal
func DetectWorkspaceFromEnv(getenv func(string) string) Workspace {
if getenv("OPENCLAW_CLI") == "1" ||
getenv("OPENCLAW_HOME") != "" ||
@@ -109,6 +117,9 @@ func DetectWorkspaceFromEnv(getenv func(string) string) Workspace {
getenv("HERMES_SESSION_KEY") != "" {
return WorkspaceHermes
}
if getenv("LARK_CHANNEL") == "1" {
return WorkspaceLarkChannel
}
return WorkspaceLocal
}
@@ -139,6 +150,7 @@ func GetBaseConfigDir() string {
// - WorkspaceLocal → GetBaseConfigDir() (unchanged, backward-compatible)
// - WorkspaceOpenClaw → GetBaseConfigDir()/openclaw
// - WorkspaceHermes → GetBaseConfigDir()/hermes
// - WorkspaceLarkChannel → GetBaseConfigDir()/lark-channel
func GetRuntimeDir() string {
base := GetBaseConfigDir()
ws := CurrentWorkspace()

View File

@@ -119,6 +119,31 @@ func TestDetectWorkspaceFromEnv(t *testing.T) {
env: map[string]string{"LARKSUITE_CLI_APP_ID": "cli_local", "LARKSUITE_CLI_APP_SECRET": "local_secret"},
expect: WorkspaceLocal,
},
{
name: "LARK_CHANNEL=1 → lark-channel",
env: map[string]string{"LARK_CHANNEL": "1"},
expect: WorkspaceLarkChannel,
},
{
name: "LARK_CHANNEL=true → local (strict ==1 check)",
env: map[string]string{"LARK_CHANNEL": "true"},
expect: WorkspaceLocal,
},
{
name: "LARK_CHANNEL=0 → local",
env: map[string]string{"LARK_CHANNEL": "0"},
expect: WorkspaceLocal,
},
{
name: "OPENCLAW_CLI=1 + LARK_CHANNEL=1 → openclaw wins (priority)",
env: map[string]string{"OPENCLAW_CLI": "1", "LARK_CHANNEL": "1"},
expect: WorkspaceOpenClaw,
},
{
name: "HERMES_HOME + LARK_CHANNEL=1 → hermes wins (priority over lark-channel)",
env: map[string]string{"HERMES_HOME": "/Users/me/.hermes", "LARK_CHANNEL": "1"},
expect: WorkspaceHermes,
},
}
for _, tt := range tests {
@@ -141,6 +166,7 @@ func TestWorkspaceDisplay(t *testing.T) {
{Workspace(""), "local"},
{WorkspaceOpenClaw, "openclaw"},
{WorkspaceHermes, "hermes"},
{WorkspaceLarkChannel, "lark-channel"},
}
for _, tt := range tests {
if got := tt.ws.Display(); got != tt.expect {
@@ -205,6 +231,13 @@ func TestGetRuntimeDir(t *testing.T) {
if got := GetRuntimeDir(); got != want {
t.Errorf("hermes: GetRuntimeDir() = %q, want %q", got, want)
}
// LarkChannel → base/lark-channel
SetCurrentWorkspace(WorkspaceLarkChannel)
want = filepath.Join(tmp, "lark-channel")
if got := GetRuntimeDir(); got != want {
t.Errorf("lark-channel: GetRuntimeDir() = %q, want %q", got, want)
}
}
func TestGetConfigPath(t *testing.T) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -146,12 +146,17 @@ function extractZipWindows(archivePath, destDir) {
"$ErrorActionPreference='Stop';" +
"Expand-Archive -LiteralPath $env:LARK_CLI_ARCHIVE -DestinationPath $env:LARK_CLI_DEST -Force";
execFileSync("powershell.exe", [...psOpts, cmdlet], { stdio: psStdio, env: psEnv });
} catch (fallbackErr) {
throw new Error(
`Failed to extract ${archivePath}. ` +
`.NET ZipFile attempt: ${primaryErr.message}. ` +
`Expand-Archive fallback: ${fallbackErr.message}`
);
} catch (secondErr) {
try {
execFileSync("tar", ["-xf", archivePath, "-C", destDir], { stdio: psStdio });
} catch (fallbackErr) {
throw new Error(
`Failed to extract ${archivePath}. ` +
`.NET ZipFile attempt: ${primaryErr.message}. ` +
`Expand-Archive fallback: ${secondErr.message}. ` +
`tar fallback: ${fallbackErr.message}`
);
}
}
}
}

View File

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

View File

@@ -4,7 +4,6 @@
package base
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
@@ -20,6 +19,9 @@ func handleBaseAPIResult(result interface{}, err error, action string) (map[stri
return dataMap, nil
}
// handleBaseAPIResultAny normalizes the Base v3 {code,msg,data} envelope used
// by shortcut APIs. Success returns data as-is; API failures become the CLI's
// structured ErrAPI, with server-provided message/hint promoted to the top level.
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
if err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
@@ -37,17 +39,34 @@ func handleBaseAPIResultAny(result interface{}, err error, action string) (inter
msg, _ = resultMap["msg"].(string)
}
fullMsg := fmt.Sprintf("%s: [%d] %s", action, larkCode, msg)
detail := extractErrorDetail(resultMap)
apiErr := output.ErrAPI(larkCode, fullMsg, detail)
if apiErr.Detail != nil && apiErr.Detail.Hint == "" {
if hint := extractErrorHint(resultMap); hint != "" {
apiErr.Detail.Hint = hint
}
apiErr := output.ErrAPI(larkCode, msg, detail)
hint := extractErrorHint(resultMap)
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
apiErr.Detail.Hint = hint
}
if apiErr.Detail != nil {
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
}
return nil, apiErr
}
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
detailMap, ok := detail.(map[string]interface{})
if !ok {
return nil
}
for key, value := range detailMap {
if value == nil {
delete(detailMap, key)
}
}
if len(detailMap) == 0 {
return nil
}
return detailMap
}
func extractErrorDetail(resultMap map[string]interface{}) interface{} {
if detail, ok := nonNilMapValue(resultMap, "error"); ok {
return detail
@@ -77,13 +96,13 @@ func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool)
func extractErrorHint(resultMap map[string]interface{}) string {
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
if hint, _ := detail["hint"].(string); strings.TrimSpace(hint) != "" {
if hint := consumeStringField(detail, "hint"); hint != "" {
return hint
}
}
data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := data["error"].(map[string]interface{}); ok {
if hint, _ := detail["hint"].(string); strings.TrimSpace(hint) != "" {
if hint := consumeStringField(detail, "hint"); hint != "" {
return hint
}
}
@@ -93,9 +112,17 @@ func extractErrorHint(resultMap map[string]interface{}) string {
func extractDataErrorMessage(resultMap map[string]interface{}) string {
data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := data["error"].(map[string]interface{}); ok {
if message, _ := detail["message"].(string); strings.TrimSpace(message) != "" {
if message := consumeStringField(detail, "message"); message != "" {
return message
}
}
return ""
}
func consumeStringField(src map[string]interface{}, key string) string {
value, _ := src[key].(string)
if _, exists := src[key]; exists {
delete(src, key)
}
return strings.TrimSpace(value)
}

View File

@@ -4,8 +4,11 @@
package base
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
func TestErrorDetailHelpers(t *testing.T) {
@@ -47,14 +50,133 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
"error": map[string]interface{}{"message": "invalid filter", "hint": "check field name"},
},
}
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") || !strings.Contains(err.Error(), "190001") {
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") {
t.Fatalf("err=%v", err)
} else {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != 190001 {
t.Fatalf("expected structured code 190001, got %v", err)
}
}
if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil {
t.Fatalf("expected error")
}
}
func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
result := map[string]interface{}{
"code": 800010407,
"msg": "cell value invalid",
"data": map[string]interface{}{
"error": map[string]interface{}{
"docs_url": nil,
"hint": "Provide a number value.",
"level": "error",
"logid": "20260508160000000000000000000000",
"message": "The cell value does not match the expected input shape.",
"path": "Amount",
"retry_after_ms": nil,
"retryable": false,
"extra_context": "future detail field",
"table": map[string]interface{}{"id": "tbl_1", "name": "Orders"},
"type": "invalid_request",
"upstream_code": nil,
"value": "abc",
},
},
}
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
errDetail := exitErr.Detail
if errDetail.Code != 800010407 {
t.Fatalf("code=%d", errDetail.Code)
}
if errDetail.Hint != "Provide a number value." {
t.Fatalf("hint=%q", errDetail.Hint)
}
detail, _ := errDetail.Detail.(map[string]interface{})
if detail == nil {
t.Fatalf("expected cleaned detail, got %#v", errDetail.Detail)
}
if _, exists := detail["message"]; exists {
t.Fatalf("detail should not repeat message: %#v", detail)
}
if _, exists := detail["hint"]; exists {
t.Fatalf("detail should not repeat hint: %#v", detail)
}
if _, exists := detail["docs_url"]; exists {
t.Fatalf("detail should omit nil docs_url: %#v", detail)
}
if detail["level"] != "error" {
t.Fatalf("detail should preserve non-duplicate fields: %#v", detail)
}
if detail["extra_context"] != "future detail field" {
t.Fatalf("detail should pass through unknown non-nil fields: %#v", detail)
}
if detail["path"] != "Amount" || detail["value"] != "abc" {
t.Fatalf("cleaned detail mismatch: %#v", detail)
}
if detail["logid"] != "20260508160000000000000000000000" {
t.Fatalf("logid=%q", detail["logid"])
}
if retryable, ok := detail["retryable"].(bool); !ok || retryable {
t.Fatalf("retryable=%v", detail["retryable"])
}
table, _ := detail["table"].(map[string]interface{})
if table["id"] != "tbl_1" || table["name"] != "Orders" {
t.Fatalf("table=%#v", detail["table"])
}
}
func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T) {
result := map[string]interface{}{
"code": output.LarkErrTokenNoPermission,
"msg": "permission denied",
"data": map[string]interface{}{
"error": map[string]interface{}{
"hint": "Grant base:record:read to the app.",
"message": "Missing required scope base:record:read.",
},
},
}
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if exitErr.Detail.Message != "Permission denied [99991676]" {
t.Fatalf("message=%q", exitErr.Detail.Message)
}
if exitErr.Detail.Detail != nil {
t.Fatalf("detail should be empty after removing message and hint: %#v", exitErr.Detail.Detail)
}
}
func TestAttachBaseResponseLogIDFromHeader(t *testing.T) {
result := map[string]interface{}{
"code": 91402,
"msg": "NOTEXIST",
"data": map[string]interface{}{},
}
attachBaseErrorLogID(result, "20260508170000000000000000000000")
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["logid"] != "20260508170000000000000000000000" {
t.Fatalf("logid=%q", detail["logid"])
}
}
type assertErr struct{}
func (assertErr) Error() string { return "network timeout" }

View File

@@ -1054,42 +1054,322 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("get", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"records": map[string]interface{}{
"schema": []interface{}{"Name", "Age"},
"record_ids": []interface{}{"rec_1"},
"rows": []interface{}{[]interface{}{"Alice", 18}},
}},
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
})
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_ids"`) || !strings.Contains(got, `"Name"`) || strings.Contains(got, `"raw"`) {
got := stdout.String()
for _, want := range []string{
"`_record_id` is metadata for record operations, not a table field.",
"- `_record_id`: rec_1",
"- `Name`: Alice",
"- `Age`: 18",
"Meta: count=1",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Alice"`) || !strings.Contains(got, `"Age"`) || strings.Contains(got, `"record":`) || strings.Contains(got, `"raw"`) {
t.Fatalf("stdout=%s", got)
}
if got := stdout.String(); !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("get passthrough fallback", func(t *testing.T) {
t.Run("get with selected fields", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_2",
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"unexpected": "shape", "record_id": "rec_2"},
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
})
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2"}, factory, stdout); err != nil {
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"unexpected": "shape"`) || strings.Contains(got, `"raw"`) || strings.Contains(got, `"record":`) {
if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Name"`) || !strings.Contains(got, `"Age"`) || !strings.Contains(got, `"Alice"`) || strings.Contains(got, `"record":`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get batch with repeated record-id flags", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_2", "rec_1"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"| _record_id | Name |",
"| rec_2 | Bob |",
"| rec_1 | Alice |",
"Meta: count=2",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get batch json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_2", "rec_1"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) || !strings.Contains(got, `"Bob"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("get batch with json selector", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_3"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{"Carol"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"],"select_fields":["Name"]}`, "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_3"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get single returns batch_get error when batch_get is unavailable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Status: 404,
Body: map[string]interface{}{"code": 404, "msg": "not found"},
}
reg.Register(batchStub)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout)
if err == nil {
t.Fatalf("expected batch_get error")
}
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
}
if stdout.Len() != 0 {
t.Fatalf("stdout=%s", stdout.String())
}
})
t.Run("get single missing record renders not found markdown", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_missing"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{nil}},
"has_more": false,
"record_not_found": []interface{}{"rec_missing"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_missing"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"Record not found.",
"- `_record_id`: rec_missing",
"Meta: count=1; has_more=false; record_not_found=1",
"Missing records: rec_missing",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "- `Name`:") {
t.Fatalf("missing record output should not render business fields:\n%s", got)
}
})
t.Run("get batch returns batch_get error when batch_get is unavailable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Status: 404,
Body: map[string]interface{}{"code": 404, "msg": "not found"},
}
reg.Register(batchStub)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout)
if err == nil {
t.Fatalf("expected batch_get error")
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
t.Fatalf("request body=%s", body)
}
if stdout.Len() != 0 {
t.Fatalf("stdout=%s", stdout.String())
}
})
t.Run("get batch with json record ids and field flags", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_4"},
"fields": []interface{}{"Status"},
"data": []interface{}{[]interface{}{"Done"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_4"]}`, "--field-id", "Status", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"Done"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_4"]`) || !strings.Contains(body, `"select_fields":["Status"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get rejects duplicate record ids", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects duplicate field ids", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "duplicate field id") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects mixed record-id and json", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects mixed field-id and json select_fields", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_2"],"select_fields":["Name"]}`, "--field-id", "Age"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "select_fields") || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects empty selection", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
t.Fatalf("err=%v", err)
}
})
t.Run("create", func(t *testing.T) {
@@ -1189,17 +1469,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("delete", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"record_id": "rec_1"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || strings.Contains(got, `"deleted": true`) {
t.Fatalf("stdout=%s", got)
}
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
}
})
t.Run("delete returns batch_delete error when unavailable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Status: 404,
Body: map[string]interface{}{"code": 404, "msg": "not found"},
}
reg.Register(batchStub)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout)
if err == nil {
t.Fatalf("expected batch_delete error")
}
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
}
if stdout.Len() != 0 {
t.Fatalf("stdout=%s", stdout.String())
}
})
t.Run("delete batch with repeated record-id flags", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_2", "rec_1"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("delete batch with json selector", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_3"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"]}`, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_3"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_3"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("delete requires yes for batch", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("err=%v", err)
}
})
t.Run("delete rejects duplicate record ids", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1", "--yes"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
t.Fatalf("err=%v", err)
}
})
t.Run("delete rejects mixed record-id and json", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`, "--yes"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("upload attachment", func(t *testing.T) {

View File

@@ -259,10 +259,15 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
name: "record get",
shortcut: BaseRecordGet,
wantHelp: []string{
"record ID",
"record ID (repeatable)",
"field ID or name to project; repeat to keep only needed columns",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
"Default output is markdown",
"projection boundary",
"record_id is already known",
"lark-base record read SOP",
},
@@ -294,6 +299,39 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
wantHelp := []string{
"complete field definition JSON object; update uses full PUT semantics, not a patch",
}
for _, want := range wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
"full field-definition PUT semantics",
"Read the current field first with +field-get",
"Type conversion is allowlist-based",
"web UI",
"Formula and lookup updates require reading the corresponding guide first.",
"lark-base skill's field-update guide",
}
for _, want := range wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}
func assertHelpOrder(t *testing.T, help string, before string, after string) {
t.Helper()
beforeIndex := strings.Index(help, before)
@@ -355,8 +393,8 @@ func TestBaseRecordValidate(t *testing.T) {
if BaseRecordSearch.Validate == nil {
t.Fatalf("record search validate should reject invalid JSON before dry-run")
}
if BaseRecordGet.Validate != nil {
t.Fatalf("record get validate should be nil")
if BaseRecordGet.Validate == nil {
t.Fatalf("record get validate should reject invalid record selection before dry-run")
}
if BaseRecordUpsert.Validate == nil {
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")

View File

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

View File

@@ -412,6 +412,11 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
if err != nil {
return nil, err
}
result, parseErr := decodeBaseV3Response(resp.RawBody)
if parseErr == nil && baseV3ResultCode(result) != 0 {
attachBaseErrorLogID(result, baseResponseLogID(resp))
return result, nil
}
if resp.StatusCode >= http.StatusBadRequest {
body := strings.TrimSpace(string(resp.RawBody))
if body == "" {
@@ -419,8 +424,15 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
}
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
}
if parseErr != nil {
return nil, parseErr
}
return result, nil
}
func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
var result map[string]interface{}
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, fmt.Errorf("response parse error: %w", err)
@@ -428,6 +440,46 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
return result, nil
}
func baseV3ResultCode(result map[string]interface{}) int {
if result == nil {
return 0
}
return toInt(result["code"])
}
func attachBaseErrorLogID(result map[string]interface{}, logID string) {
if result == nil || strings.TrimSpace(logID) == "" {
return
}
logID = strings.TrimSpace(logID)
if detail, ok := result["error"].(map[string]interface{}); ok {
if _, exists := detail["logid"]; !exists {
detail["logid"] = logID
}
return
}
data, _ := result["data"].(map[string]interface{})
if data == nil {
data = map[string]interface{}{}
result["data"] = data
}
detail, _ := data["error"].(map[string]interface{})
if detail == nil {
detail = map[string]interface{}{}
data["error"] = detail
}
if _, exists := detail["logid"]; !exists {
detail["logid"] = logID
}
}
func baseResponseLogID(resp *larkcore.ApiResp) string {
if resp == nil {
return ""
}
return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
}
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
result, err := baseV3Raw(runtime, method, path, params, data)
return handleBaseAPIResult(result, err, "API call failed")

View File

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

View File

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

View File

@@ -13,17 +13,29 @@ import (
var BaseRecordGet = common.Shortcut{
Service: "base",
Command: "+record-get",
Description: "Get a record by ID",
Description: "Get one or more records by ID",
Risk: "read",
Scopes: []string{"base:record:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
recordRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
recordReadFormatFlag(),
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
return validateRecordSelection(runtime)
},
Tips: []string{
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"Example with projection: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use --field-id as a projection boundary to avoid loading large cell values into context when they are not needed.",
"Use +record-get when record_id is already known; otherwise use +record-search or +record-list.",
"Agent hint: follow the lark-base record read SOP for record read routing.",
},

View File

@@ -24,6 +24,10 @@ func validateRecordReadFormat(runtime *common.RuntimeContext) error {
}
func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordMarkdown)
}
func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) error {
if runtime.JqExpr != "" {
if !runtime.Changed("format") {
runtime.Out(data, nil)
@@ -31,7 +35,7 @@ func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interf
}
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
}
rendered, err := renderRecordMarkdown(data)
rendered, err := renderer(data)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
runtime.Out(data, nil)
@@ -48,6 +52,27 @@ func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interf
return nil
}
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
}
func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
fields := stringSliceValue(data["fields"])
recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok {
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
}
if len(recordIDs) == 1 && len(rows) == 1 {
rowItems, _ := rows[0].([]interface{})
if recordMarkedNotFound(data["record_not_found"], recordIDs[0]) {
return renderMissingSingleRecordMarkdown(recordIDs[0], data), nil
}
return renderSingleRecordMarkdown(recordIDs[0], fields, rowItems, data), nil
}
return renderRecordMarkdown(data)
}
func renderRecordMarkdown(data map[string]interface{}) (string, error) {
fields := stringSliceValue(data["fields"])
recordIDs := stringSliceValue(data["record_id_list"])
@@ -91,9 +116,68 @@ func renderRecordMarkdown(data map[string]interface{}) (string, error) {
b.WriteString(ignored)
b.WriteByte('\n')
}
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
b.WriteString("Missing records: ")
b.WriteString(missing)
b.WriteByte('\n')
}
return b.String(), nil
}
func renderSingleRecordMarkdown(recordID string, fields []string, rowItems []interface{}, data map[string]interface{}) string {
var b strings.Builder
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
b.WriteString("- `_record_id`: ")
b.WriteString(markdownInlineValue(recordID))
b.WriteByte('\n')
for i, field := range fields {
b.WriteString("- `")
b.WriteString(field)
b.WriteString("`: ")
if i < len(rowItems) {
b.WriteString(markdownInlineValue(rowItems[i]))
}
b.WriteByte('\n')
}
meta := recordMarkdownMeta(data)
if len(meta) > 0 {
b.WriteString("\nMeta: ")
b.WriteString(strings.Join(meta, "; "))
b.WriteByte('\n')
}
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
b.WriteString("Ignored fields: ")
b.WriteString(ignored)
b.WriteByte('\n')
}
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
b.WriteString("Missing records: ")
b.WriteString(missing)
b.WriteByte('\n')
}
return b.String()
}
func renderMissingSingleRecordMarkdown(recordID string, data map[string]interface{}) string {
var b strings.Builder
b.WriteString("Record not found.\n\n")
b.WriteString("- `_record_id`: ")
b.WriteString(markdownInlineValue(recordID))
b.WriteByte('\n')
meta := recordMarkdownMeta(data)
if len(meta) > 0 {
b.WriteString("\nMeta: ")
b.WriteString(strings.Join(meta, "; "))
b.WriteByte('\n')
}
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
b.WriteString("Missing records: ")
b.WriteString(missing)
b.WriteByte('\n')
}
return b.String()
}
func recordMarkdownMeta(data map[string]interface{}) []string {
meta := []string{fmt.Sprintf("count=%d", ignoredFieldsCount(data["record_id_list"]))}
if hasMore, ok := data["has_more"]; ok {
@@ -109,6 +193,9 @@ func recordMarkdownMeta(data map[string]interface{}) []string {
if ignoredCount := ignoredFieldsCount(data["ignored_fields"]); ignoredCount > 0 {
meta = append(meta, fmt.Sprintf("ignored_fields=%d", ignoredCount))
}
if missingCount := ignoredFieldsCount(data["record_not_found"]); missingCount > 0 {
meta = append(meta, fmt.Sprintf("record_not_found=%d", missingCount))
}
return meta
}
@@ -138,6 +225,19 @@ func ignoredFieldsMarkdown(value interface{}) string {
return strings.Join(items, ", ")
}
func recordNotFoundMarkdown(value interface{}) string {
return strings.Join(markdownListItems(value), ", ")
}
func recordMarkedNotFound(value interface{}, recordID string) bool {
for _, item := range markdownListItems(value) {
if item == recordID {
return true
}
}
return false
}
func markdownListItems(value interface{}) []string {
switch v := value.(type) {
case []interface{}:

View File

@@ -83,6 +83,75 @@ func TestRenderRecordMarkdownEscapesTableCells(t *testing.T) {
}
}
func TestRenderRecordGetMarkdownSingleRecordUsesKVLayout(t *testing.T) {
got, err := renderRecordGetMarkdown(map[string]interface{}{
"fields": []interface{}{"Name|Label", "Note"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"- `_record_id`: rec_1",
"- `Name|Label`: A|B",
"- `Note`: line1\nline2",
"Meta: count=1; has_more=false",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordGetMarkdownSingleMissingRecordUsesNotFoundLayout(t *testing.T) {
got, err := renderRecordGetMarkdown(map[string]interface{}{
"fields": []interface{}{"Name", "Note"},
"record_id_list": []interface{}{"rec_missing"},
"data": []interface{}{[]interface{}{nil, nil}},
"record_not_found": []interface{}{"rec_missing"},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"Record not found.",
"- `_record_id`: rec_missing",
"Meta: count=1; has_more=false; record_not_found=1",
"Missing records: rec_missing",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "- `Name`:") {
t.Fatalf("missing record layout should not render business fields:\n%s", got)
}
}
func TestRenderRecordMarkdownIncludesMissingRecords(t *testing.T) {
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_missing"},
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{nil}},
"record_not_found": []interface{}{"rec_missing"},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"Meta: count=2; has_more=false; record_not_found=1",
"Missing records: rec_missing",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordMarkdownTruncatesIgnoredFields(t *testing.T) {
ignored := make([]interface{}, maxRecordMarkdownIgnoredFields+2)
for i := range ignored {

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,15 @@
package convertlib
import (
"encoding/json"
"fmt"
"math"
"net/url"
"reflect"
"strconv"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -148,6 +154,27 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
msg["reply_to"] = pid
}
// Preserve API-provided fields (even if this formatter doesn't otherwise use them).
if v, ok := m["chat_id"]; ok {
msg["chat_id"] = v
}
if v, ok := m["message_position"]; ok {
msg["message_position"] = v
}
if v, ok := m["thread_message_position"]; ok {
msg["thread_message_position"] = v
}
// Prefer API-provided message_app_link when it's a non-empty string; otherwise assemble deterministically.
appLink, _ := m["message_app_link"].(string)
appLink = strings.TrimSpace(appLink)
if appLink == "" && runtime != nil && runtime.Config != nil {
appLink = assembleMessageAppLink(m, runtime.Config.Brand)
}
if appLink != "" {
msg["message_app_link"] = appLink
}
if len(mentions) > 0 {
simplified := make([]map[string]interface{}, 0, len(mentions))
for _, raw := range mentions {
@@ -166,6 +193,150 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
return msg
}
func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) string {
domain := resolveAppLinkDomain(brand)
if domain == "" {
return ""
}
chatID, _ := m["chat_id"].(string)
threadID, _ := m["thread_id"].(string)
msgPos, okMsgPos := normalizeMessagePosition(m["message_position"])
threadPos, okThreadPos := normalizeMessagePosition(m["thread_message_position"])
// Thread app link requires both thread_id and chat_id.
// Emit both underscore-less (openthreadid/openchatid) and snake_case (open_thread_id/open_chat_id)
// query keys so PC and mobile clients can both resolve the link.
if threadID != "" && chatID != "" && okThreadPos {
u := &url.URL{Scheme: "https", Host: domain, Path: "/client/thread/open"}
q := url.Values{}
q.Set("openthreadid", threadID)
q.Set("openchatid", chatID)
q.Set("open_thread_id", threadID)
q.Set("open_chat_id", chatID)
q.Set("thread_position", threadPos)
u.RawQuery = q.Encode()
return u.String()
}
if chatID != "" && okMsgPos {
u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"}
q := url.Values{}
q.Set("openChatId", chatID)
q.Set("position", msgPos)
u.RawQuery = q.Encode()
return u.String()
}
return ""
}
func normalizeMessagePosition(v interface{}) (string, bool) {
if v == nil {
return "", false
}
switch vv := v.(type) {
case float32:
f := float64(vv)
if math.IsNaN(f) || math.IsInf(f, 0) {
return "", false
}
if math.Trunc(f) == f {
return strconv.FormatInt(int64(f), 10), true
}
return strconv.FormatFloat(f, 'f', -1, 64), true
case float64:
if math.IsNaN(vv) || math.IsInf(vv, 0) {
return "", false
}
if math.Trunc(vv) == vv {
return strconv.FormatInt(int64(vv), 10), true
}
return strconv.FormatFloat(vv, 'f', -1, 64), true
case int:
return strconv.Itoa(vv), true
case int8:
return strconv.FormatInt(int64(vv), 10), true
case int16:
return strconv.FormatInt(int64(vv), 10), true
case int32:
return strconv.FormatInt(int64(vv), 10), true
case int64:
return strconv.FormatInt(vv, 10), true
case uint:
return strconv.FormatUint(uint64(vv), 10), true
case uint8:
return strconv.FormatUint(uint64(vv), 10), true
case uint16:
return strconv.FormatUint(uint64(vv), 10), true
case uint32:
return strconv.FormatUint(uint64(vv), 10), true
case uint64:
return strconv.FormatUint(vv, 10), true
case uintptr:
return strconv.FormatUint(uint64(vv), 10), true
case json.Number:
s := strings.TrimSpace(vv.String())
if s == "" {
return "", false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return "", false
}
if math.Trunc(f) == f {
return strconv.FormatInt(int64(f), 10), true
}
return strconv.FormatFloat(f, 'f', -1, 64), true
case string:
s := strings.TrimSpace(vv)
if s == "" {
return "", false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return "", false
}
if math.Trunc(f) == f {
return strconv.FormatInt(int64(f), 10), true
}
return strconv.FormatFloat(f, 'f', -1, 64), true
default:
// Fallback for typed numeric values (e.g. int32/uint64 via struct -> interface{}), pointers, etc.
rv := reflect.ValueOf(v)
for rv.Kind() == reflect.Ptr {
if rv.IsNil() {
return "", false
}
rv = rv.Elem()
}
switch rv.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(rv.Int(), 10), true
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return strconv.FormatUint(rv.Uint(), 10), true
case reflect.Float32, reflect.Float64:
f := rv.Float()
if math.IsNaN(f) || math.IsInf(f, 0) {
return "", false
}
if math.Trunc(f) == f {
return strconv.FormatInt(int64(f), 10), true
}
return strconv.FormatFloat(f, 'f', -1, 64), true
default:
return "", false
}
}
}
func resolveAppLinkDomain(brand core.LarkBrand) string {
appLink := core.ResolveEndpoints(brand).AppLink
u, err := url.Parse(appLink)
if err != nil {
return ""
}
return u.Host
}
// extractMentionOpenId extracts open_id from mention id (string or {"open_id":...} object).
func extractMentionOpenId(id interface{}) string {
if s, ok := id.(string); ok {

View File

@@ -4,11 +4,44 @@
package convertlib
import (
"encoding/json"
"math"
"net/url"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
)
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("url.Parse(%q) error: %v", raw, err)
}
return u
}
func assertURLHasQuery(t *testing.T, raw, host, path string, want map[string]string) {
t.Helper()
u := mustParseURL(t, raw)
if u.Scheme != "https" {
t.Fatalf("url scheme = %q, want https (%q)", u.Scheme, raw)
}
if u.Host != host {
t.Fatalf("url host = %q, want %q (%q)", u.Host, host, raw)
}
if u.Path != path {
t.Fatalf("url path = %q, want %q (%q)", u.Path, path, raw)
}
q := u.Query()
for k, v := range want {
if got := q.Get(k); got != v {
t.Fatalf("query[%q] = %q, want %q (%q)", k, got, v, raw)
}
}
}
func TestConvertBodyContent(t *testing.T) {
ctx := &ConvertContext{RawContent: `{"text":"hello"}`}
@@ -62,6 +95,300 @@ func TestFormatMessageItem(t *testing.T) {
}
}
func TestResolveAppLinkDomain(t *testing.T) {
if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" {
t.Fatalf("resolveAppLinkDomain(feishu) = %q", got)
}
if got := resolveAppLinkDomain(core.BrandLark); got != "applink.larksuite.com" {
t.Fatalf("resolveAppLinkDomain(lark) = %q", got)
}
if got := resolveAppLinkDomain(core.LarkBrand("other")); got != "applink.feishu.cn" {
t.Fatalf("resolveAppLinkDomain(other) = %q, want feishu", got)
}
}
func TestFormatMessageItem_MessageAppLink_PassThrough(t *testing.T) {
runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}}
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_123",
"create_time": "1710500000",
"chat_id": "oc_1",
"message_position": 12,
"message_app_link": "https://applink.feishu.cn/client/chat/open?openChatId=oc_1&position=12",
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, runtime)
if got["message_app_link"] != raw["message_app_link"] {
t.Fatalf("FormatMessageItem() message_app_link = %#v, want pass-through", got["message_app_link"])
}
}
func TestFormatMessageItem_MessageAppLink_AssembleChat(t *testing.T) {
runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}}
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_123",
"create_time": "1710500000",
"chat_id": "oc_1",
"message_position": float64(12),
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, runtime)
assertURLHasQuery(t, got["message_app_link"].(string), "applink.feishu.cn", "/client/chat/open", map[string]string{
"openChatId": "oc_1",
"position": "12",
})
}
func TestFormatMessageItem_MessageAppLink_AssembleThread(t *testing.T) {
runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandLark}}
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_123",
"create_time": "1710500000",
"chat_id": "oc_1",
"thread_id": "omt_1",
"thread_message_position": "9",
"message_position": 12,
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, runtime)
assertURLHasQuery(t, got["message_app_link"].(string), "applink.larksuite.com", "/client/thread/open", map[string]string{
"openthreadid": "omt_1",
"openchatid": "oc_1",
"open_thread_id": "omt_1",
"open_chat_id": "oc_1",
"thread_position": "9",
})
}
func TestFormatMessageItem_MessageAppLink_FallbackToChatWhenThreadPositionInvalid(t *testing.T) {
runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}}
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_123",
"create_time": "1710500000",
"chat_id": "oc_1",
"thread_id": "omt_1",
"thread_message_position": "bad",
"message_position": "12",
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, runtime)
assertURLHasQuery(t, got["message_app_link"].(string), "applink.feishu.cn", "/client/chat/open", map[string]string{
"openChatId": "oc_1",
"position": "12",
})
}
func TestFormatMessageItem_MessageAppLink_BrandUnknownDefaultsToFeishu(t *testing.T) {
runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.LarkBrand("other")}}
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_123",
"create_time": "1710500000",
"chat_id": "oc_1",
"message_position": 12,
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, runtime)
assertURLHasQuery(t, got["message_app_link"].(string), "applink.feishu.cn", "/client/chat/open", map[string]string{
"openChatId": "oc_1",
"position": "12",
})
}
func TestNormalizeMessagePosition_TypedIntsAndUints(t *testing.T) {
if got, ok := normalizeMessagePosition(int32(-3)); !ok || got != "-3" {
t.Fatalf("normalizeMessagePosition(int32(-3)) = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition(uint64(9)); !ok || got != "9" {
t.Fatalf("normalizeMessagePosition(uint64(9)) = (%q,%v)", got, ok)
}
}
func TestNormalizeMessagePosition_CoversMoreNumericTypesAndInvalidInputs(t *testing.T) {
// ints
if got, ok := normalizeMessagePosition(int8(-1)); !ok || got != "-1" {
t.Fatalf("normalizeMessagePosition(int8(-1)) = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition(int16(2)); !ok || got != "2" {
t.Fatalf("normalizeMessagePosition(int16(2)) = (%q,%v)", got, ok)
}
// uints
if got, ok := normalizeMessagePosition(uint(3)); !ok || got != "3" {
t.Fatalf("normalizeMessagePosition(uint(3)) = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition(uintptr(4)); !ok || got != "4" {
t.Fatalf("normalizeMessagePosition(uintptr(4)) = (%q,%v)", got, ok)
}
// float32
if got, ok := normalizeMessagePosition(float32(1)); !ok || got != "1" {
t.Fatalf("normalizeMessagePosition(float32(1)) = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition(float64(1.5)); !ok || got != "1.5" {
t.Fatalf("normalizeMessagePosition(float64(1.5)) = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition(float64(-1.5)); !ok || got != "-1.5" {
t.Fatalf("normalizeMessagePosition(float64(-1.5)) = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition(float32(math.NaN())); ok || got != "" {
t.Fatalf("normalizeMessagePosition(float32(NaN)) = (%q,%v), want ('',false)", got, ok)
}
if got, ok := normalizeMessagePosition(float32(math.Inf(1))); ok || got != "" {
t.Fatalf("normalizeMessagePosition(float32(+Inf)) = (%q,%v), want ('',false)", got, ok)
}
// json.Number invalid
if got, ok := normalizeMessagePosition(json.Number("1.5")); !ok || got != "1.5" {
t.Fatalf("normalizeMessagePosition(json.Number(1.5)) = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition(json.Number("bad")); ok || got != "" {
t.Fatalf("normalizeMessagePosition(json.Number(bad)) = (%q,%v), want ('',false)", got, ok)
}
if got, ok := normalizeMessagePosition(json.Number("1e309")); ok || got != "" {
t.Fatalf("normalizeMessagePosition(json.Number(1e309)) = (%q,%v), want ('',false)", got, ok)
}
// string invalid
if got, ok := normalizeMessagePosition(" 1.5 "); !ok || got != "1.5" {
t.Fatalf("normalizeMessagePosition(\" 1.5 \") = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition(" "); ok || got != "" {
t.Fatalf("normalizeMessagePosition(blank) = (%q,%v), want ('',false)", got, ok)
}
if got, ok := normalizeMessagePosition("not-a-number"); ok || got != "" {
t.Fatalf("normalizeMessagePosition(not-a-number) = (%q,%v), want ('',false)", got, ok)
}
// reflect fallback: pointers
i := int32(7)
if got, ok := normalizeMessagePosition(&i); !ok || got != "7" {
t.Fatalf("normalizeMessagePosition(*int32(7)) = (%q,%v)", got, ok)
}
u := uint64(8)
if got, ok := normalizeMessagePosition(&u); !ok || got != "8" {
t.Fatalf("normalizeMessagePosition(*uint64(8)) = (%q,%v)", got, ok)
}
f := float64(2.25)
if got, ok := normalizeMessagePosition(&f); !ok || got != "2.25" {
t.Fatalf("normalizeMessagePosition(*float64(2.25)) = (%q,%v)", got, ok)
}
fNaN := float64(math.NaN())
if got, ok := normalizeMessagePosition(&fNaN); ok || got != "" {
t.Fatalf("normalizeMessagePosition(*float64(NaN)) = (%q,%v), want ('',false)", got, ok)
}
var nilPtr *int
if got, ok := normalizeMessagePosition(nilPtr); ok || got != "" {
t.Fatalf("normalizeMessagePosition(nil ptr) = (%q,%v), want ('',false)", got, ok)
}
if got, ok := normalizeMessagePosition(struct{}{}); ok || got != "" {
t.Fatalf("normalizeMessagePosition(struct{}) = (%q,%v), want ('',false)", got, ok)
}
}
func TestAssembleMessageAppLink_EncodesQueryValues(t *testing.T) {
// chat link encoding
chat := map[string]interface{}{
"chat_id": "oc_1+2/3",
"message_position": 12,
}
gotChat := assembleMessageAppLink(chat, core.BrandFeishu)
assertURLHasQuery(t, gotChat, "applink.feishu.cn", "/client/chat/open", map[string]string{
"openChatId": "oc_1+2/3",
"position": "12",
})
// thread link encoding
thread := map[string]interface{}{
"chat_id": "oc_1+2/3",
"thread_id": "omt_1+2/3",
"thread_message_position": -1,
}
gotThread := assembleMessageAppLink(thread, core.BrandFeishu)
assertURLHasQuery(t, gotThread, "applink.feishu.cn", "/client/thread/open", map[string]string{
"open_thread_id": "omt_1+2/3",
"open_chat_id": "oc_1+2/3",
"openthreadid": "omt_1+2/3",
"openchatid": "oc_1+2/3",
"thread_position": "-1",
})
}
func TestFormatMessageItem_MessageAppLink_NonStringDoesNotLeakNull(t *testing.T) {
runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}}
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_123",
"create_time": "1710500000",
"chat_id": "oc_1",
"message_position": 12,
"message_app_link": nil,
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, runtime)
// Should assemble instead of emitting JSON null.
assertURLHasQuery(t, got["message_app_link"].(string), "applink.feishu.cn", "/client/chat/open", map[string]string{
"openChatId": "oc_1",
"position": "12",
})
}
func TestFormatMessageItem_MessageAppLink_RuntimeNilNoAssemble(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_123",
"create_time": "1710500000",
"chat_id": "oc_1",
"message_position": 12,
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, nil)
if _, ok := got["message_app_link"]; ok {
t.Fatalf("FormatMessageItem() should not assemble without runtime, got %#v", got["message_app_link"])
}
}
func TestFormatMessageItem_MessageAppLink_MissingFieldsNoPanic(t *testing.T) {
runtime := &common.RuntimeContext{Config: &core.CliConfig{Brand: core.BrandFeishu}}
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_123",
"create_time": "1710500000",
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, runtime)
if _, ok := got["message_app_link"]; ok {
t.Fatalf("FormatMessageItem() message_app_link should be absent when fields are missing, got %#v", got["message_app_link"])
}
}
func TestNormalizeMessagePosition_AllowsZeroAndNegative(t *testing.T) {
if got, ok := normalizeMessagePosition("0"); !ok || got != "0" {
t.Fatalf("normalizeMessagePosition(\"0\") = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition("-3"); !ok || got != "-3" {
t.Fatalf("normalizeMessagePosition(\"-3\") = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition(float64(0)); !ok || got != "0" {
t.Fatalf("normalizeMessagePosition(0.0) = (%q,%v)", got, ok)
}
if got, ok := normalizeMessagePosition(float64(-1)); !ok || got != "-1" {
t.Fatalf("normalizeMessagePosition(-1.0) = (%q,%v)", got, ok)
}
}
func TestExtractMentionOpenIdAndTruncateContent(t *testing.T) {
if got := extractMentionOpenId("ou_1"); got != "ou_1" {
t.Fatalf("extractMentionOpenId(string) = %q", got)

View File

@@ -489,6 +489,9 @@ func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (st
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
if url := common.GetString(data, "url"); url != "" {
fmt.Fprintf(w, "url: %s\n", url)
}
version := common.GetString(data, "version")
if version == "" {
version = common.GetString(data, "data_version")

View File

@@ -79,6 +79,9 @@ var MarkdownCreate = common.Shortcut{
"file_name": finalMarkdownFileName(spec),
"size_bytes": fileSize,
}
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
out["url"] = u
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
out["permission_grant"] = grant
}

View File

@@ -467,6 +467,9 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
if !strings.Contains(stdout.String(), `"file_name": "README.md"`) {
t.Fatalf("stdout missing file_name: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"url": "https://www.feishu.cn/file/box_md_create"`) {
t.Fatalf("stdout missing url: %s", stdout.String())
}
}
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
@@ -497,6 +500,9 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
if !strings.Contains(out, "file_token: box_md_create_pretty") {
t.Fatalf("pretty output missing file_token: %s", out)
}
if !strings.Contains(out, "url: https://www.feishu.cn/file/box_md_create_pretty") {
t.Fatalf("pretty output missing url: %s", out)
}
if !strings.Contains(out, "permission_grant.status: skipped") {
t.Fatalf("pretty output missing permission_grant.status: %s", out)
}

View File

@@ -0,0 +1,421 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func parseValues2DJSON(raw string) ([][]interface{}, error) {
var rows [][]interface{}
if err := json.Unmarshal([]byte(raw), &rows); err != nil {
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
}
if rows == nil {
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
}
return rows, nil
}
var SheetRead = common.Shortcut{
Service: "sheets",
Command: "+read",
Description: "Read spreadsheet cell values",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "read range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
{Name: "sheet-id", Desc: "sheet ID"},
{Name: "value-render-option", Desc: "render option: ToString|FormattedValue|Formula|UnformattedValue"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
readRange := runtime.Str("range")
if readRange == "" && runtime.Str("sheet-id") != "" {
readRange = runtime.Str("sheet-id")
}
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range").
Set("token", token).Set("range", readRange)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
readRange := runtime.Str("range")
if readRange == "" && runtime.Str("sheet-id") != "" {
readRange = runtime.Str("sheet-id")
}
if readRange == "" {
var err error
readRange, err = getFirstSheetID(runtime, token)
if err != nil {
return err
}
}
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
params := map[string]interface{}{}
renderOption := runtime.Str("value-render-option")
if renderOption != "" {
params["valueRenderOption"] = renderOption
}
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetWrite = common.Shortcut{
Service: "sheets",
Command: "+write",
Description: "Write to spreadsheet cells (overwrite mode)",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "write range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
{Name: "sheet-id", Desc: "sheet ID"},
{Name: "values", Desc: "2D array JSON", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if _, err := parseValues2DJSON(runtime.Str("values")); err != nil {
return err
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
writeRange := runtime.Str("range")
if writeRange == "" && runtime.Str("sheet-id") != "" {
writeRange = runtime.Str("sheet-id")
}
values, _ := parseValues2DJSON(runtime.Str("values"))
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/values").
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
values, err := parseValues2DJSON(runtime.Str("values"))
if err != nil {
return err
}
writeRange := runtime.Str("range")
if writeRange == "" && runtime.Str("sheet-id") != "" {
writeRange = runtime.Str("sheet-id")
}
if writeRange == "" {
var err error
writeRange, err = getFirstSheetID(runtime, token)
if err != nil {
return err
}
}
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{
"range": writeRange,
"values": values,
},
})
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetAppend = common.Shortcut{
Service: "sheets",
Command: "+append",
Description: "Append rows to a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "append range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
{Name: "sheet-id", Desc: "sheet ID"},
{Name: "values", Desc: "2D array JSON", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if _, err := parseValues2DJSON(runtime.Str("values")); err != nil {
return err
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
appendRange := runtime.Str("range")
if appendRange == "" && runtime.Str("sheet-id") != "" {
appendRange = runtime.Str("sheet-id")
}
values, _ := parseValues2DJSON(runtime.Str("values"))
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/values_append").
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
values, err := parseValues2DJSON(runtime.Str("values"))
if err != nil {
return err
}
appendRange := runtime.Str("range")
if appendRange == "" && runtime.Str("sheet-id") != "" {
appendRange = runtime.Str("sheet-id")
}
if appendRange == "" {
var err error
appendRange, err = getFirstSheetID(runtime, token)
if err != nil {
return err
}
}
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{
"range": appendRange,
"values": values,
},
})
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetFind = common.Shortcut{
Service: "sheets",
Command: "+find",
Description: "Find cells in a spreadsheet",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "find", Desc: "search text", Required: true},
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 / C2 with --sheet-id)"},
{Name: "ignore-case", Type: "bool", Desc: "case-insensitive search"},
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell"},
{Name: "search-by-regex", Type: "bool", Desc: "regex search"},
{Name: "include-formulas", Type: "bool", Desc: "search formulas"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
sheetID := runtime.Str("sheet-id")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": !runtime.Bool("ignore-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range"))
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/find").
Body(map[string]interface{}{
"find": runtime.Str("find"),
"find_condition": findCondition,
}).
Set("token", token).Set("sheet_id", sheetID).Set("find", runtime.Str("find"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
sheetID := runtime.Str("sheet-id")
findText := runtime.Str("find")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": !runtime.Bool("ignore-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range"))
}
reqData := map[string]interface{}{
"find_condition": findCondition,
"find": findText,
}
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetReplace = common.Shortcut{
Service: "sheets",
Command: "+replace",
Description: "Find and replace cell values in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "find", Desc: "search text or regex pattern", Required: true},
{Name: "replacement", Desc: "replacement text", Required: true},
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 with --sheet-id)"},
{Name: "match-case", Type: "bool", Desc: "case-sensitive search"},
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"},
{Name: "search-by-regex", Type: "bool", Desc: "use regex search"},
{Name: "include-formulas", Type: "bool", Desc: "search in formulas"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
sheetID := runtime.Str("sheet-id")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": runtime.Bool("match-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace").
Body(map[string]interface{}{
"find_condition": findCondition,
"find": runtime.Str("find"),
"replacement": runtime.Str("replacement"),
}).
Set("token", token).Set("sheet_id", sheetID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
sheetID := runtime.Str("sheet-id")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": runtime.Bool("match-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
validate.EncodePathSegment(token),
validate.EncodePathSegment(sheetID),
),
nil,
map[string]interface{}{
"find_condition": findCondition,
"find": runtime.Str("find"),
"replacement": runtime.Str("replacement"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -6,6 +6,7 @@ package sheets
import (
"context"
"fmt"
"io/fs"
"path/filepath"
"github.com/larksuite/cli/internal/output"
@@ -43,6 +44,10 @@ var SheetWriteImage = common.Shortcut{
if err := validateSingleCellRange(runtime.Str("range")); err != nil {
return err
}
_, _, err := validateSheetWriteImageFile(runtime.Str("image"))
if err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -71,25 +76,12 @@ var SheetWriteImage = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
// Resolve the target cell range (--range is required).
pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
// Resolve image file.
imagePath := runtime.Str("image")
safePath, err := validate.SafeInputPath(imagePath)
safePath, stat, err := validateSheetWriteImageFile(imagePath)
if err != nil {
return output.ErrValidation("unsafe image path: %s", err)
}
stat, err := vfs.Stat(safePath)
if err != nil {
return output.ErrValidation("image file not found: %s", imagePath)
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("image must be a regular file: %s", imagePath)
}
const maxImageSize int64 = 20 * 1024 * 1024 // 20 MB
if stat.Size() > maxImageSize {
return output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
return err
}
imageBytes, err := vfs.ReadFile(safePath)
@@ -104,8 +96,6 @@ var SheetWriteImage = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange)
// The sheets v2 values_image API expects a JSON body with the image
// as an inline byte array, not multipart/form-data.
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"range": pointRange,
"image": imageBytes,
@@ -118,3 +108,22 @@ var SheetWriteImage = common.Shortcut{
return nil
},
}
func validateSheetWriteImageFile(imagePath string) (string, fs.FileInfo, error) {
safePath, err := validate.SafeInputPath(imagePath)
if err != nil {
return "", nil, output.ErrValidation("unsafe image path: %s", err)
}
stat, err := vfs.Stat(safePath)
if err != nil {
return "", nil, output.ErrValidation("image file not found: %s", imagePath)
}
if !stat.Mode().IsRegular() {
return "", nil, output.ErrValidation("image must be a regular file: %s", imagePath)
}
const maxImageSize int64 = 20 * 1024 * 1024
if stat.Size() > maxImageSize {
return "", nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
}
return safePath, stat, nil
}

View File

@@ -0,0 +1,350 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func validateBatchStyleData(raw string) error {
var data interface{}
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
}
arr, ok := data.([]interface{})
if !ok || len(arr) == 0 {
return common.FlagErrorf("--data must be a non-empty JSON array")
}
for i, item := range arr {
entry, ok := item.(map[string]interface{})
if !ok {
return common.FlagErrorf("--data[%d] must be an object with ranges and style", i)
}
rangesRaw, ok := entry["ranges"]
if !ok {
return common.FlagErrorf("--data[%d].ranges is required", i)
}
ranges, ok := rangesRaw.([]interface{})
if !ok || len(ranges) == 0 {
return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i)
}
for j, r := range ranges {
s, ok := r.(string)
if !ok || s == "" {
return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j)
}
if _, _, ok := splitSheetRange(s); !ok {
return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s)
}
}
styleRaw, ok := entry["style"]
if !ok {
return common.FlagErrorf("--data[%d].style is required", i)
}
if _, ok := styleRaw.(map[string]interface{}); !ok {
return common.FlagErrorf("--data[%d].style must be a JSON object", i)
}
}
return nil
}
var SheetSetStyle = common.Shortcut{
Service: "sheets",
Command: "+set-style",
Description: "Set cell style for a range",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
{Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
}
if _, ok := style.(map[string]interface{}); !ok {
return common.FlagErrorf("--style must be a JSON object, got %T", style)
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
json.Unmarshal([]byte(runtime.Str("style")), &style)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
Body(map[string]interface{}{
"appendStyle": map[string]interface{}{
"range": r,
"style": style,
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
}
data, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"appendStyle": map[string]interface{}{
"range": r,
"style": style,
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetBatchSetStyle = common.Shortcut{
Service: "sheets",
Command: "+batch-set-style",
Description: "Batch set cell styles for multiple ranges",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
return validateBatchStyleData(runtime.Str("data"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
var data interface{}
json.Unmarshal([]byte(runtime.Str("data")), &data)
normalizeBatchStyleRanges(data)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
Body(map[string]interface{}{
"data": data,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
var data interface{}
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
}
normalizeBatchStyleRanges(data)
result, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"data": data,
},
)
if err != nil {
return err
}
runtime.Out(result, nil)
return nil
},
}
func normalizeBatchStyleRanges(data interface{}) {
items, ok := data.([]interface{})
if !ok {
return
}
for _, item := range items {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
ranges, ok := entry["ranges"].([]interface{})
if !ok {
continue
}
for i, r := range ranges {
if s, ok := r.(string); ok {
ranges[i] = normalizePointRange("", s)
}
}
}
}
var SheetMergeCells = common.Shortcut{
Service: "sheets",
Command: "+merge-cells",
Description: "Merge cells in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
{Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells").
Body(map[string]interface{}{
"range": r,
"mergeType": runtime.Str("merge-type"),
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"range": r,
"mergeType": runtime.Str("merge-type"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUnmergeCells = common.Shortcut{
Service: "sheets",
Command: "+unmerge-cells",
Description: "Unmerge (split) cells in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells").
Body(map[string]interface{}{
"range": r,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"range": r,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -7,11 +7,21 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func filterViewBasePath(token, sheetID string) string {
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views",
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
}
func filterViewItemPath(token, sheetID, filterViewID string) string {
return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID))
}
func filterViewConditionBasePath(token, sheetID, filterViewID string) string {
return fmt.Sprintf("%s/conditions", filterViewItemPath(token, sheetID, filterViewID))
}
@@ -20,6 +30,226 @@ func filterViewConditionItemPath(token, sheetID, filterViewID, conditionID strin
return fmt.Sprintf("%s/%s", filterViewConditionBasePath(token, sheetID, filterViewID), validate.EncodePathSegment(conditionID))
}
func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) {
return validateSheetManageToken(runtime)
}
func hasNonEmptyStringFlag(runtime *common.RuntimeContext, name string) bool {
return runtime.Cmd.Flags().Changed(name) && strings.TrimSpace(runtime.Str(name)) != ""
}
var SheetCreateFilterView = common.Shortcut{
Service: "sheets",
Command: "+create-filter-view",
Description: "Create a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true},
{Name: "filter-view-name", Desc: "display name (max 100 chars)"},
{Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range must not be empty")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{"range": runtime.Str("range")}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
if s := runtime.Str("filter-view-id"); s != "" {
body["filter_view_id"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{"range": runtime.Str("range")}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
if s := runtime.Str("filter-view-id"); s != "" {
body["filter_view_id"] = s
}
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateFilterView = common.Shortcut{
Service: "sheets",
Command: "+update-filter-view",
Description: "Update a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "range", Desc: "new filter range"},
{Name: "filter-view-name", Desc: "new display name (max 100 chars)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
if !hasNonEmptyStringFlag(runtime, "range") &&
!hasNonEmptyStringFlag(runtime, "filter-view-name") {
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
return common.NewDryRunAPI().
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetListFilterViews = common.Shortcut{
Service: "sheets",
Command: "+list-filter-views",
Description: "List all filter views in a sheet",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetGetFilterView = common.Shortcut{
Service: "sheets",
Command: "+get-filter-view",
Description: "Get a filter view by ID",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetDeleteFilterView = common.Shortcut{
Service: "sheets",
Command: "+delete-filter-view",
Description: "Delete a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetCreateFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+create-filter-view-condition",
@@ -83,9 +313,9 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
if !runtime.Cmd.Flags().Changed("filter-type") &&
!runtime.Cmd.Flags().Changed("compare-type") &&
!runtime.Cmd.Flags().Changed("expected") {
if !hasNonEmptyStringFlag(runtime, "filter-type") &&
!hasNonEmptyStringFlag(runtime, "compare-type") &&
!hasNonEmptyStringFlag(runtime, "expected") {
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
}
if s := runtime.Str("expected"); s != "" {
@@ -227,7 +457,6 @@ var SheetDeleteFilterViewCondition = common.Shortcut{
},
}
// validateExpectedFlag checks that --expected is a valid JSON array.
func validateExpectedFlag(s string) error {
if s == "" {
return nil
@@ -239,7 +468,6 @@ func validateExpectedFlag(s string) error {
return nil
}
// buildConditionBody constructs the request body for condition create/update.
func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} {
body := map[string]interface{}{}
if includeConditionID {
@@ -253,7 +481,6 @@ func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool)
}
if s := runtime.Str("expected"); s != "" {
var arr []interface{}
// Validate already ensures this is a valid JSON array.
_ = json.Unmarshal([]byte(s), &arr)
body["expected"] = arr
}

View File

@@ -6,11 +6,164 @@ package sheets
import (
"context"
"fmt"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const sheetImageParentType = "sheet_image"
var SheetMediaUpload = common.Shortcut{
Service: "sheets",
Command: "+media-upload",
Description: "Upload a local image for use as a floating image and return the file_token",
Risk: "write",
Scopes: []string{"docs:document.media:upload"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSheetMediaUploadParent(runtime); err != nil {
return err
}
_, _, err := validateSheetMediaUploadFile(runtime, runtime.Str("file"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
parentNode, err := resolveSheetMediaUploadParent(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
filePath := runtime.Str("file")
fileName := filepath.Base(filePath)
dry := common.NewDryRunAPI()
if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) {
dry.Desc("chunked media upload (files > 20MB)").
POST("/open-apis/drive/v1/medias/upload_prepare").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": sheetImageParentType,
"parent_node": parentNode,
"size": "<file_size>",
}).
POST("/open-apis/drive/v1/medias/upload_part").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/medias/upload_finish").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return dry.Set("spreadsheet_token", parentNode)
}
return dry.Desc("multipart/form-data upload").
POST("/open-apis/drive/v1/medias/upload_all").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": sheetImageParentType,
"parent_node": parentNode,
"size": "<file_size>",
"file": "@" + filePath,
}).
Set("spreadsheet_token", parentNode)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
parentNode, err := resolveSheetMediaUploadParent(runtime)
if err != nil {
return err
}
filePath := runtime.Str("file")
safePath, stat, err := validateSheetMediaUploadFile(runtime, filePath)
if err != nil {
return err
}
fileName := filepath.Base(safePath)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n",
fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode))
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
fileToken, err := uploadSheetMediaFile(runtime, safePath, fileName, stat.Size(), parentNode)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"size": stat.Size(),
"spreadsheet_token": parentNode,
}, nil)
return nil
},
}
func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) {
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return "", nil, common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return "", nil, output.ErrValidation("file must be a regular file: %s", filePath)
}
return filePath, stat, nil
}
func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if u := runtime.Str("url"); u != "" {
if parsed := extractSpreadsheetToken(u); parsed != "" {
token = parsed
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
pn := parentNode
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: sheetImageParentType,
ParentNode: &pn,
})
}
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: sheetImageParentType,
ParentNode: parentNode,
})
}
func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
info, err := fio.Stat(filePath)
if err != nil {
return false
}
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
}
func floatImageBasePath(token, sheetID string) string {
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images",
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
@@ -46,9 +199,6 @@ func validateFloatImageRange(sheetID, rangeVal string) error {
return nil
}
// validateFloatImageUpdatePayload rejects an update request that carries no
// mutable field. Without this, PATCH {} reaches the server as a confusing
// no-op or opaque error.
func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
hasField := runtime.Str("range") != "" ||
runtime.Cmd.Flags().Changed("width") ||
@@ -61,12 +211,6 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
return nil
}
// validateFloatImageDims checks the numeric bounds we can verify without
// fetching cell dimensions: width/height >= 20 and offset-x/offset-y >= 0.
// The upper bounds (offset < anchor cell's width/height) are validated by
// the server and surfaced through the 1310246 error hint.
// Only flags explicitly supplied by the user are checked, so omitted flags
// (which fall back to server defaults) pass through unchanged.
func validateFloatImageDims(runtime *common.RuntimeContext) error {
if runtime.Cmd.Flags().Changed("width") {
if v := runtime.Int("width"); v < 20 {
@@ -116,7 +260,6 @@ func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[
return body
}
// SheetCreateFloatImage creates a float image on a sheet.
var SheetCreateFloatImage = common.Shortcut{
Service: "sheets",
Command: "+create-float-image",
@@ -170,7 +313,6 @@ var SheetCreateFloatImage = common.Shortcut{
},
}
// SheetUpdateFloatImage updates a float image's properties.
var SheetUpdateFloatImage = common.Shortcut{
Service: "sheets",
Command: "+update-float-image",
@@ -220,7 +362,6 @@ var SheetUpdateFloatImage = common.Shortcut{
},
}
// SheetGetFloatImage retrieves a single float image.
var SheetGetFloatImage = common.Shortcut{
Service: "sheets",
Command: "+get-float-image",
@@ -255,7 +396,6 @@ var SheetGetFloatImage = common.Shortcut{
},
}
// SheetListFloatImages queries all float images in a sheet.
var SheetListFloatImages = common.Shortcut{
Service: "sheets",
Command: "+list-float-images",
@@ -289,7 +429,6 @@ var SheetListFloatImages = common.Shortcut{
},
}
// SheetDeleteFloatImage deletes a float image.
var SheetDeleteFloatImage = common.Shortcut{
Service: "sheets",
Command: "+delete-float-image",

View File

@@ -0,0 +1,369 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetAddDimension = common.Shortcut{
Service: "sheets",
Command: "+add-dimension",
Description: "Add rows or columns at the end of a sheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
length := runtime.Int("length")
if length < 1 || length > 5000 {
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"length": runtime.Int("length"),
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"length": runtime.Int("length"),
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetInsertDimension = common.Shortcut{
Service: "sheets",
Command: "+insert-dimension",
Description: "Insert rows or columns at a specified position",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true},
{Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
}
if runtime.Int("end-index") <= runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be greater than --start-index")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
body := map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}
if s := runtime.Str("inherit-style"); s != "" {
body["inheritStyle"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range").
Body(body).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
body := map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}
if s := runtime.Str("inherit-style"); s != "" {
body["inheritStyle"] = s
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
nil, body,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateDimension = common.Shortcut{
Service: "sheets",
Command: "+update-dimension",
Description: "Update row or column properties (visibility, size)",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
{Name: "visible", Type: "bool", Desc: "true to show, false to hide"},
{Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
}
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
return common.FlagErrorf("--fixed-size must be >= 1")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
props := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("visible") {
props["visible"] = runtime.Bool("visible")
}
if runtime.Cmd.Flags().Changed("fixed-size") {
props["fixedSize"] = runtime.Int("fixed-size")
}
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
"dimensionProperties": props,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
props := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("visible") {
props["visible"] = runtime.Bool("visible")
}
if runtime.Cmd.Flags().Changed("fixed-size") {
props["fixedSize"] = runtime.Int("fixed-size")
}
data, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
"dimensionProperties": props,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetMoveDimension = common.Shortcut{
Service: "sheets",
Command: "+move-dimension",
Description: "Move rows or columns to a new position",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true},
{Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true},
{Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
if runtime.Int("destination-index") < 0 {
return common.FlagErrorf("--destination-index must be >= 0")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension").
Body(map[string]interface{}{
"source": map[string]interface{}{
"major_dimension": runtime.Str("dimension"),
"start_index": runtime.Int("start-index"),
"end_index": runtime.Int("end-index"),
},
"destination_index": runtime.Int("destination-index"),
}).
Set("token", token).
Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
validate.EncodePathSegment(token),
validate.EncodePathSegment(runtime.Str("sheet-id")),
),
nil,
map[string]interface{}{
"source": map[string]interface{}{
"major_dimension": runtime.Str("dimension"),
"start_index": runtime.Int("start-index"),
"end_index": runtime.Int("end-index"),
},
"destination_index": runtime.Int("destination-index"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetDeleteDimension = common.Shortcut{
Service: "sheets",
Command: "+delete-dimension",
Description: "Delete rows or columns",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("DELETE",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -527,6 +527,65 @@ func TestSheetBatchSetStyleValidateSuccess(t *testing.T) {
}
}
func TestSheetBatchSetStyleValidateRejectsMalformedEntries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data string
wantSubst string
}{
{
name: "entry must be object",
data: `["bad"]`,
wantSubst: "must be an object with ranges and style",
},
{
name: "ranges required",
data: `[{"style":{}}]`,
wantSubst: ".ranges is required",
},
{
name: "ranges must be array",
data: `[{"ranges":"sheet1!A1","style":{}}]`,
wantSubst: ".ranges must be a non-empty array of strings",
},
{
name: "ranges must not be empty",
data: `[{"ranges":[],"style":{}}]`,
wantSubst: ".ranges must be a non-empty array of strings",
},
{
name: "range must include sheet prefix",
data: `[{"ranges":["A1"],"style":{}}]`,
wantSubst: "must include a sheetId! prefix",
},
{
name: "style required",
data: `[{"ranges":["sheet1!A1:B2"]}]`,
wantSubst: ".style is required",
},
{
name: "style must be object",
data: `[{"ranges":["sheet1!A1:B2"],"style":"bad"}]`,
wantSubst: ".style must be a JSON object",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": tt.data,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
}
})
}
}
func TestSheetBatchSetStyleDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{

View File

@@ -5,6 +5,7 @@ package sheets
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
@@ -144,6 +145,23 @@ func TestSheetCreateFallbackURLWhenBackendOmitsIt(t *testing.T) {
}
}
func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{
"title": "项目排期",
"folder-token": "fldcn123",
"headers": "",
"data": "",
},
nil, nil)
got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt))
if !strings.Contains(got, `"folder_token":"fldcn123"`) {
t.Fatalf("DryRun should include folder_token, got: %s", got)
}
}
func TestSheetCreatePreservesBackendURL(t *testing.T) {
t.Parallel()

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