Compare commits

...

23 Commits

Author SHA1 Message Date
liuxinyang.lxy
cbd729757b docs(event): slim lark-event references to recipes + gotchas; CLI owns structure
- references (im/vc/minutes/whiteboard): drop key catalogs, field/scope tables
  and other content already emitted by `event list`/`event schema`; keep only
  jq recipes, domain semantic gotchas, required params, and per-key identity.
  ~55% token cut across the four references (4343 -> 1940, cl100k_base).
- vc: document the 3 previously-undocumented recording keys, then replace the
  hardcoded key catalog with a pointer to `event list` to stop doc-vs-code drift.
- SKILL.md Topic index: reword rows to advertise recipes/gotchas and route
  structure lookups to the CLI, matching the slimmed references.

Validated by a command-correctness eval (skill+CLI available): 21/21 across the
four domains, identity correct per key, whiteboard required `-p whiteboard_id`
preserved; the VC recording-transcript key (previously unanswerable from the
skill alone) now resolves.

Change-Id: I666e9706ae6ef3e2bbcd56a3fb70c4f8be94182c
2026-06-13 18:14:19 +08:00
liangshuo-1
5a806febc8 fix: reduce base shortcut token overhead (#1426 squashed)
Squashed net diff of PR #1426 (github-base-token-improve): slim
lark-base SKILL.md + formula/workflow references, split the workflow
guide/schema into per-step references, add guess-tolerant flag aliases
and misuse hints for base record shortcuts (with containment-based
suggest), and drive import adjustments.

Combined onto eval/skills-combined alongside #1450 #1389 #1395 #1410.

Change-Id: Iab1f0c4f1a4c93fd9dbd49bc702cb0ef41022bda
2026-06-13 18:11:37 +08:00
lijiayi.2333
aaced42956 docs(mail): trim lark-mail skill context
Change-Id: I40c3d0429b1352949b5b09a79c5700b26fca955d
2026-06-13 18:10:59 +08:00
luozhixiong
a55bd76966 docs: trim lark-im description under 200-token skill gate
Drop the redundant Feed-pin parenthetical from the SKILL.md frontmatter description so it fits the non-waivable <=200-token skill-quality gate (206 -> 194, cl100k_base). Behavior-neutral: only the WHEN-routing copy is shortened; skill body and commands are unchanged. Mirrors the same one-line trim in larksuite-cli-registry skill-meta.yaml (im.description).
2026-06-13 18:09:27 +08:00
luozhixiong
c15cb120a1 docs: slim and reorder lark-im SKILL.md (4253 -> 2040 tokens)
Regenerate lark-im SKILL.md (4253 -> 2040 tokens, tiktoken cl100k_base, -52%)
via per-domain gen-skills flags. Drop the scope permission table and the
duplicated schema-usage note, compact API Resources to a terse identity index,
render the Shortcuts table as one-line routing, and move Shortcuts ahead of the
concept sections (executable-first). Full detail stays in --help / schema (the
source of truth); Go Desc and --help are unchanged. Add the NOT boundary, a
good/bad sender example, Flag-type rationale, and anti-hallucination guidance
with a safe fallback (guide users to the Feishu client; do not auto-execute
high-risk raw-API writes).

The master template is variabilized with defaults preserving current output, so
other domains regenerate byte-identical (verified).

Eval (skillave, IM, 9 cases x 3 runs, lark-cli-eval-analyze two-table compare):
mean pass_rate 0.939 -> 0.969, duration -22%, tool-calls -26%, zero structural
regression; anti-hallucination case 0.58 -> 1.00.

Depends on larksuite-cli-registry change (skill-meta.yaml per-domain flags +
gen-skills.py); must merge together with (or before) this PR.

Change-Id: I33c62664388542bfd8ba85ea06c1c5a493c0d935
2026-06-13 18:09:21 +08:00
mayang.my
93e4fc7af0 docs: improve task skill scoped resolution
Change-Id: I4068d821adf1b249d00950a8f6ff53414825aeff
2026-06-13 18:02:14 +08:00
liangshuo-1
feed8fb884 docs(lark-shared): restructure into prioritized rules + on-demand references
Rewrite the always-loaded SKILL.md from a 168-line monolith into a slim
core: a positioning line plus 7 mental-model "通用准则" ordered by agent
attention priority (silent/proactive rules first; loud-triggered and
low-frequency ones last), and a short routing list. Mechanics and edge
cases move into on-demand references/ (loaded only when relevant).

References (named with the lark-shared- prefix, matching the per-domain
convention):
- lark-shared-auth-split-flow.md     split-flow steps (marked must-read)
- lark-shared-high-risk-approval.md  exit-10 envelope forms + predict/preview
- lark-shared-identity-and-permissions.md  identity model + scope recovery
- lark-shared-config-init.md         first-run config (blocking, no split-flow)
- lark-shared-update-notice.md       _notice handling (update/skills/deprecated)

Fix doc-vs-implementation drift confirmed against the code:
- exit-10 keys on exit code 10, not the type string; covers both the flat
  (type=confirmation_required) and typed (type=confirmation + subtype)
  envelopes, and reads the confirm flag from hint (--yes / --force).
- distinguish permission_violations (raw API) vs missing_scopes (CLI error).
- complete _notice keys (update / skills / deprecated_command).
- identity failure is silent-or-loud per command, not always empty.

Switch description to Chinese; bump version 1.0.0 -> 1.1.0.

Change-Id: I2dff478ecdc05a13f2d750944f637ed2374961e7
2026-06-13 17:58:07 +08:00
raistlin042
0fbfe68726 docs: drop Miaoda brand word from apps command help text (#1399) 2026-06-13 14:00:30 +08:00
liangshuo-1
e1af7e3018 chore: release v1.0.53 (#1443)
]
2026-06-12 20:03:08 +08:00
bubbmon233
693e299589 docs(mail): clarify message read shortcuts (#1261)
* docs(mail): clarify message read shortcuts

Update mail read shortcut help, docs, and triage guidance so single-message and multi-message reads are routed to the right commands.

Add focused tests for help text, dry-run copy, triage stderr hints, and batch_get chunking behavior.

sprint: S1

* docs(mail): align batch_get limit with gateway config

* docs(mail): use shell-safe batch message id examples

* docs(mail): trim batch_get pagination wording

* docs(mail): use placeholder style for message ids

* docs(mail): hide batch_get internals from help
2026-06-12 19:52:36 +08:00
Yuxuan Zhao
69f335be7c test(calendar): drop flaky calendar list e2e checks (#1441) 2026-06-12 19:00:09 +08:00
JackZhao10086
d1a0926dd6 feat/revoke token (#1434) 2026-06-12 17:49:33 +08:00
syh-cpdsss
008bdda861 docs(whiteboard): optimize whiteboard skill (#1371)
* docs(whiteboard): optimize whiteboard skill

Change-Id: Iabcbe9f4e309ae9f467ceec265320cea6cdfa81b

* fix: PR issue

Change-Id: I96d99037b3ba74a3ea9964991b67cdf15fb985be
2026-06-12 17:46:55 +08:00
syh-cpdsss
f1da8c274b docs(okr): optimize okr skill (#1368)
Change-Id: I095a3a7a935e4f84459d1be24015f59cd9e324a6
2026-06-12 17:46:27 +08:00
AlbertSun
842be3fdc5 feat(token): mint TAT via unified OAuth v3 Token Endpoint (#1408) 2026-06-12 17:44:07 +08:00
raistlin042
1cd7a88597 fix: read release error_logs from data.error_logs in apps +release-get (#1436) 2026-06-12 16:58:47 +08:00
max
7c64e63b9d feat(note): clarify note ownership with dedicated detail and transcript flows (#1435)
* feat: split note domain

* fix: address note transcript review comments

* fix: stabilize empty note detail detection
2026-06-12 16:30:41 +08:00
luozhixiong01
8e60f01474 feat(im): unify sort flags into --sort field and --order direction (#1302)
The 4 im query commands had three inconsistent sort conventions and leaked upstream API jargon (ByCreateTimeAsc, member_count_desc) directly to users. This PR unifies them on a single rule — --sort selects a field, --order selects a direction, both from fixed enums — so an agent only ever picks from an enum, never constructs a string. Old flags (--sort-type, --sort-by, and --sort on messages/threads) are kept as hidden silent aliases (no deprecation warning), so existing scripts keep working byte-for-byte.
2026-06-12 15:27:54 +08:00
JackZhao10086
465c789f7c feat: add --json flag support to auth subcommands (#1431)
* feat: add --json flag support to auth subcommands

* feat(auth/logout): add json output support for logout command

* feat(auth/list): add json output support for auth list command
2026-06-12 15:04:14 +08:00
Yuxuan Zhao
2a7e9c7d0d test(drive): retry duplicate-remote push in live E2E (#1403) 2026-06-12 13:48:19 +08:00
liangshuo-1
76ba6fad4f chore: add CODEOWNERS for internal/ and new skills domains (#1420) 2026-06-12 11:19:25 +08:00
liangshuo-1
510545f1e5 refactor(vc): consolidate note handling back into the vc domain (#1417) 2026-06-12 00:44:35 +08:00
max
c11cf3b716 feat: split note domain (#1345)
Add note shortcuts for note detail and unified transcript retrieval, route vc note detail parsing through the note domain, and update note/vc/minutes skill guidance for normal versus unified transcript handling.

Includes dry-run E2E coverage for the new note shortcuts and documents the remaining live E2E fixture gap.
2026-06-11 22:38:29 +08:00
189 changed files with 7006 additions and 3895 deletions

30
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,30 @@
/internal/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1
/skills/lark-approval/
/skills/lark-apps/
/skills/lark-attendance/
/skills/lark-base/
/skills/lark-calendar/
/skills/lark-contact/
/skills/lark-doc/
/skills/lark-drive/
/skills/lark-event/
/skills/lark-im/
/skills/lark-mail/
/skills/lark-markdown/
/skills/lark-minutes/
/skills/lark-okr/
/skills/lark-openapi-explorer/
/skills/lark-shared/
/skills/lark-sheets/
/skills/lark-skill-maker/
/skills/lark-slides/
/skills/lark-task/
/skills/lark-vc/
/skills/lark-vc-agent/
/skills/lark-whiteboard/
/skills/lark-wiki/
/skills/lark-workflow-meeting-summary/
/skills/lark-workflow-standup-report/

2
.gitignore vendored
View File

@@ -35,6 +35,8 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json
cmd/api/download.bin
app.log

View File

@@ -2,6 +2,25 @@
All notable changes to this project will be documented in this file.
## [v1.0.53] - 2026-06-12
### Features
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
- **auth**: Add `--json` flag support to auth subcommands (#1431)
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
### Bug Fixes
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
### Documentation
- **skills**: Optimize whiteboard skill (#1371)
- **skills**: Optimize okr skill (#1368)
## [v1.0.52] - 2026-06-11
### Features
@@ -1130,6 +1149,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50

View File

@@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "pretty", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -19,6 +19,7 @@ import (
type CheckOptions struct {
Factory *cmdutil.Factory
Scope string
JSON bool
}
// NewCmdAuthCheck creates the auth check subcommand.
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read")

View File

@@ -18,6 +18,7 @@ import (
// ListOptions holds all inputs for auth list.
type ListOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthList creates the auth list subcommand.
@@ -34,6 +35,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
@@ -44,6 +46,14 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_configured",
})
return nil
}
// 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
@@ -61,6 +71,14 @@ func authListRun(opts *ListOptions) error {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil
}

View File

@@ -4,6 +4,7 @@
package auth
import (
"encoding/json"
"strings"
"testing"
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
}
}
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
// 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
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
t.Errorf("agent hint must not mention config init: %s", out)
}
}
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "No logged-in users") {
t.Errorf("stderr = %q, want no-users hint", stderr.String())
}
}

View File

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

View File

@@ -9,6 +9,7 @@ import (
"errors"
"io"
"net/http"
"slices"
"sort"
"strings"
"testing"
@@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
}
}
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {

View File

@@ -18,6 +18,7 @@ import (
// LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthLogout creates the auth logout subcommand.
@@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -44,25 +46,65 @@ func authLogoutRun(opts *LogoutOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_configured",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil
}
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users {
if httpErr == nil && secretErr == nil {
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
revokeToken := token.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = token.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
}
}
}
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
}
}
app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": true,
})
return nil
}
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil
}

356
cmd/auth/logout_test.go Normal file
View File

@@ -0,0 +1,356 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/url"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != true {
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
}
if _, hasReason := payload["reason"]; hasReason {
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "Logged out") {
t.Errorf("stderr = %q, want success text", stderr.String())
}
}
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-refresh-token" &&
values.Get("token_type_hint") == "refresh_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Status: 500,
Body: map[string]interface{}{"error": "server_error"},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
gotErr := stderr.String()
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
}
if !strings.Contains(gotErr, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", gotErr)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}

View File

@@ -19,6 +19,7 @@ type ScopesOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Format string
JSON bool
}
// NewCmdAuthScopes creates the auth scopes subcommand.
@@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
}
if runF != nil {
return runF(opts)
}
@@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -17,6 +17,7 @@ import (
type StatusOptions struct {
Factory *cmdutil.Factory
Verify bool
JSON bool
}
// NewCmdAuthStatus creates the auth status subcommand.
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
}
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -33,15 +33,16 @@ const probeTimeout = 3 * time.Second
//
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the server deterministically rejected the credentials — a
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
// so the root dispatcher renders the canonical envelope and `config init`
// exits non-zero — identical to how every other token-resolving command
// reports the same bad credentials. Ambiguous failures (transport errors,
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
// errors and are swallowed (return nil), so valid configurations are never
// disturbed by upstream noise. errs.IsTyped is the discriminator.
// only when the unified Token Endpoint deterministically rejected the
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
// typed error is propagated so the root dispatcher renders the canonical
// envelope and `config init` exits non-zero — identical to how every other
// token-resolving command reports the same bad credentials. Ambiguous
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
// timeouts) come back as raw untyped errors and are swallowed (return nil),
// so valid configurations are never disturbed by upstream noise.
// errs.IsTyped is the discriminator.
//
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always

View File

@@ -31,10 +31,10 @@ type fakeRT struct {
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
@@ -84,14 +84,15 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
}
// assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
// the expected upstream code. This is the same typed error every other
// token-resolving command returns for the same bad credentials, and nothing is
// written to stderr (the root dispatcher renders the envelope).
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
// is the same typed error every other token-resolving command returns for the
// same bad credentials, and nothing is written to stderr (the root dispatcher
// renders the envelope). The numeric code is not asserted: the unified v3 Token
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err == nil {
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
t.Fatal("expected *errs.ConfigError, got nil")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
@@ -103,9 +104,6 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCo
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != wantCode {
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
@@ -123,11 +121,13 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
}
}
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
// propagated. The probe endpoint must not be called when TAT fails.
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
@@ -137,28 +137,27 @@ func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
}
assertConfigRejection(t, err, errBuf, 10003)
assertConfigRejection(t, err, errBuf)
}
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
// the most common real-world rejection, propagated.
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
// unauthorized_client is treated as the same credential rejection, propagated.
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
// Any non-zero body code is a deterministic rejection and propagates (typed).
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
// typed, so the probe still surfaces it rather than swallowing.
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
// it rather than swallowing — but is not a credential (ConfigError) rejection.
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)

View File

@@ -47,6 +47,7 @@ type DeviceFlowResult struct {
// OAuthEndpoints contains the OAuth endpoint URLs.
type OAuthEndpoints struct {
DeviceAuthorization string
Revoke string
Token string
}
@@ -55,6 +56,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
ep := core.ResolveEndpoints(brand)
return OAuthEndpoints{
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
Revoke: ep.Accounts + PathOAuthRevoke,
Token: ep.Open + PathOAuthTokenV2,
}
}

View File

@@ -31,6 +31,9 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
t.Errorf("Revoke = %q", ep.Revoke)
}
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token)
}
@@ -42,6 +45,9 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
t.Errorf("Revoke = %q", ep.Revoke)
}
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token)
}

View File

@@ -7,6 +7,8 @@ package auth
const (
// PathDeviceAuthorization is the endpoint for device authorization.
PathDeviceAuthorization = "/oauth/v1/device_authorization"
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
PathOAuthRevoke = "/oauth/v1/revoke"
// PathAppRegistration is the endpoint for application registration.
PathAppRegistration = "/oauth/v1/app/registration"
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).

131
internal/auth/revoke.go Normal file
View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// RevokeToken revokes a previously issued OAuth token.
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
endpoints := ResolveOAuthEndpoints(brand)
form := url.Values{}
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
form.Set("token", token)
if tokenTypeHint != "" {
form.Set("token_type_hint", tokenTypeHint)
}
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
if err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
}
if resp.StatusCode >= 400 {
return revokeHTTPStatusError(resp.StatusCode, body)
}
if len(body) == 0 {
return nil
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil
}
if code := getInt(data, "code", 0); code != 0 {
msg := getStr(data, "msg")
if msg == "" {
msg = getStr(data, "message")
}
if msg == "" {
msg = "unknown error"
}
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
WithCode(code).
WithCause(errors.New(msg))
}
if errStr := getStr(data, "error"); errStr != "" {
msg := getStr(data, "error_description")
if msg == "" {
msg = errStr
}
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
WithCause(errors.New(msg))
}
return nil
}
func revokeHTTPStatusError(status int, body []byte) error {
msg := formatOAuthErrorBody(body)
cause := errors.New(strings.TrimSpace(string(body)))
if strings.TrimSpace(string(body)) == "" {
cause = errors.New(msg)
}
if status >= http.StatusInternalServerError {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
WithCode(status).
WithRetryable().
WithCause(cause)
}
subtype := errs.SubtypeUnknown
if status == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
WithCode(status).
WithCause(cause)
}
func formatOAuthErrorBody(body []byte) string {
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
return "empty response"
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return trimmed
}
if msg := getStr(data, "error_description"); msg != "" {
return msg
}
if msg := getStr(data, "msg"); msg != "" {
return msg
}
if msg := getStr(data, "message"); msg != "" {
return msg
}
if msg := getStr(data, "error"); msg != "" {
return msg
}
return trimmed
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
type errReadCloser struct {
err error
}
func (r errReadCloser) Read(_ []byte) (int, error) {
return 0, r.err
}
func (r errReadCloser) Close() error {
return nil
}
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
stub := &httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_a" &&
values.Get("client_secret") == "secret_b" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
}
reg.Register(stub)
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err != nil {
t.Fatalf("RevokeToken() error = %v", err)
}
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
t.Fatalf("Content-Type = %q", got)
}
}
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
sentinel := errors.New("transport down")
httpClient := &http.Client{
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, sentinel
}),
}
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem = %#v, want network/transport", p)
}
if !errors.Is(err, sentinel) {
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
}
}
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Status: 400,
Body: map[string]interface{}{"error": "invalid_token"},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI || p.Code != 400 {
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
}
if !strings.Contains(err.Error(), "invalid_token") {
t.Fatalf("expected invalid_token error, got %v", err)
}
}
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{
"code": 12345,
"msg": "invalid revoke state",
},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI || p.Code != 12345 {
t.Fatalf("problem = %#v, want api error with code 12345", p)
}
if !strings.Contains(err.Error(), "invalid revoke state") {
t.Fatalf("expected oauth error message, got %v", err)
}
}
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{
"error": "invalid_token",
"error_description": "token already expired",
},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("problem = %#v, want api error", p)
}
if !strings.Contains(err.Error(), "token already expired") {
t.Fatalf("expected oauth error_description, got %v", err)
}
}
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
sentinel := errors.New("read failed")
httpClient := &http.Client{
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: errReadCloser{err: sentinel},
Header: make(http.Header),
}, nil
}),
}
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem = %#v, want internal/invalid_response", p)
}
if !errors.Is(err, sentinel) {
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
}
if !strings.Contains(err.Error(), "token revoke read error") {
t.Fatalf("expected read error message, got %v", err)
}
if _, ok := err.(*errs.InternalError); !ok {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
}

View File

@@ -22,6 +22,12 @@ func ParseBrand(value string) LarkBrand {
return BrandFeishu
}
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
// domain. It serves every grant type (client_credentials for TAT,
// authorization_code / device_code / refresh_token for UAT) and replaces the
// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal).
const OAuthTokenV3Path = "/oauth/v3/token"
// Endpoints holds resolved endpoint URLs for different Lark services.
type Endpoints struct {
Open string // e.g. "https://open.feishu.cn"

View File

@@ -42,6 +42,11 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
if ep.Open != "https://open.feishu.cn" {
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
}
// The unified OAuth v3 Token Endpoint mints TAT on the accounts domain;
// pin the default-brand host so a stray non-production domain revert is caught.
if ep.Accounts != "https://accounts.feishu.cn" {
t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts)
}
}
func TestResolveOpenBaseURL(t *testing.T) {

View File

@@ -19,33 +19,44 @@ import (
extcred "github.com/larksuite/cli/extension/credential"
)
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
// canonical typed error. The TAT mint endpoint reports invalid credentials
// with two distinct codes:
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint
// reports failures using the OAuth 2.0 model — an `error` string plus an
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
//
// - 10003: bad app_id format or non-existent app_id ("invalid param")
// - 10014: invalid app_secret ("app secret invalid")
//
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
// the configured credentials cannot mint a tenant access token. 10014 is
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
// 10003 is NOT globally mapped because in other Lark endpoints it carries
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
// the override stays local to this TAT call site instead of leaking into the
// shared codemeta table.
func classifyTATResponseCode(code int, msg, brand, appID string) error {
if code == 10003 {
// invalid_client / unauthorized_client mean the configured app_id/app_secret
// cannot mint a token; from the user's perspective that is the same actionable
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced.
// Every other deterministic error falls through to BuildAPIError, which still
// yields a typed error so probe callers (errs.IsTyped) surface it rather than
// swallowing it. Transient/server-side failures (5xx / server_error) are
// filtered out by FetchTAT before this is called, so they stay untyped.
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error {
msg := errDesc
if msg == "" {
msg = oauthErr
}
switch oauthErr {
case "invalid_client", "unauthorized_client":
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
WithCode(code).
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
}
return errclass.BuildAPIError(map[string]any{
if err := errclass.BuildAPIError(map[string]any{
"code": code,
"msg": msg,
}, errclass.ClassifyContext{
Brand: brand,
AppID: appID,
})
}); err != nil {
return err
}
// BuildAPIError returns nil for code 0 (Feishu's success convention), but this
// function is only reached once FetchTAT has ruled out success — a non-credential
// OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a
// deterministic rejection. Back it with a typed APIError so callers never receive
// the ("", nil) "empty token, no error" pair.
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code)
}
// DefaultAccountProvider resolves account from config.json via keychain.
@@ -146,8 +157,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
return &TokenResult{Token: token, Scopes: scopes}, nil
}
// resolveTAT resolves a tenant access token. Result is cached after first call.
// NOTE: Uses sync.Once — only the context from the first call is used.
// resolveTAT resolves a tenant access token. The result is cached after the first
// call via sync.Once — only the context from the first call is used.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx)

View File

@@ -19,18 +19,16 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
var _ DefaultAccountResolver = &DefaultAccountProvider{}
}
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
// which from the user's perspective is the same actionable failure as 10014
// ("app secret invalid") — both mean the configured credentials cannot mint a
// tenant access token. The global codemeta intentionally does not map 10003
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
// API uses it for permission denied), so the override is local to this site.
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the
// unified Token Endpoint's OAuth2 invalid_client error surfaces as
// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a
// tenant access token, the same actionable failure the legacy 10003/10014 codes
// produced. The numeric code is intentionally not asserted: the v3 endpoint may
// return invalid_client with no Lark code (code defaults to 0).
func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) {
err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code=10003")
t.Fatal("expected non-nil error for invalid_client")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
@@ -42,22 +40,16 @@ func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
if cfgErr.Hint == "" {
t.Error("Hint must be non-empty so the user gets a recovery action")
}
}
// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
// goes through the global BuildAPIError path (codemeta entry) so the override
// for 10003 does not regress the existing mapping.
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code=10014")
}
// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that
// unauthorized_client is treated as the same credential failure as
// invalid_client.
func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) {
err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x")
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
@@ -65,21 +57,38 @@ func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10014 {
t.Errorf("Code = %d, want 10014", cfgErr.Code)
}
}
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
// the credential set fall through to the generic BuildAPIError fallback
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
// TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors
// outside the credential set fall through to the generic BuildAPIError fallback
// — still typed, but not a ConfigError. The mapping is narrow and intentional.
func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) {
err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for unmapped code")
t.Fatal("expected non-nil error for invalid_scope")
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
}
}
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError
// returns nil for code 0 (Feishu's success convention); without the backstop,
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token
// with no error.
func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
}
}

View File

@@ -4,46 +4,47 @@
package credential
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
)
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
// given credentials. It does not read configuration or keychain, so callers
// that already hold plaintext credentials (e.g. the post-`config init` probe)
// can validate them without a second keychain round-trip.
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the
// client_credentials grant with client_secret_post authentication. It does not
// read configuration or keychain, so callers that already hold plaintext
// credentials (e.g. the post-`config init` probe) can validate them without a
// second keychain round-trip.
//
// A non-zero TAT response code means the server inspected the payload and
// rejected the credentials; FetchTAT returns the canonical typed error from
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
// every token-resolving command) produces, so callers see one consistent
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
// credential rejection apart from upstream/transport noise.
// A deterministic client-side rejection (e.g. invalid_client) returns the
// canonical typed error from classifyTATResponseCode — the SAME classification
// doResolveTAT (and thus every token-resolving command) produces, so callers
// see one consistent envelope. Transport failures, unreadable/unparseable
// bodies, and transient server-side failures (5xx / server_error) are returned
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a
// deterministic credential rejection apart from upstream/transport noise.
//
// The caller owns the context timeout.
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
ep := core.ResolveEndpoints(brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
endpoint := ep.Accounts + core.OAuthTokenV3Path
body, err := json.Marshal(map[string]string{
"app_id": appID,
"app_secret": appSecret,
})
if err != nil {
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", appID)
form.Set("client_secret", appSecret)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
@@ -51,20 +52,51 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("failed to read TAT response: %w", err)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Code int `json:"code"`
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
Msg string `json:"msg"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse TAT response: %w", err)
if err := json.Unmarshal(body, &result); err != nil {
// An unparseable body is ambiguous (covers non-JSON error pages and
// truncated payloads); stay untyped so probe callers treat it as noise.
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
}
if result.Code != 0 {
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
if result.Code == 0 && result.AccessToken != "" {
return result.AccessToken, nil
}
return result.TenantAccessToken, nil
// Transient/server-side failures stay untyped so probe callers stay silent and
// retryers can back off; only deterministic client rejections are typed. Covers
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
// behavior so a rate-limited probe is not surfaced as a hard credential error.
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
result.Error == "slow_down" {
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
}
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
if result.Code == 0 && result.Error == "" {
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
}
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
// non-empty typed message instead of a bare "API error: [code]".
desc := result.ErrorDescription
if desc == "" {
desc = result.Msg
}
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
}

View File

@@ -44,7 +44,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
func TestFetchTAT_Success(t *testing.T) {
rt := &stubRoundTripper{
respCode: 200,
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`,
}
hc := &http.Client{Transport: rt}
@@ -55,24 +55,33 @@ func TestFetchTAT_Success(t *testing.T) {
if token != "t-abc" {
t.Errorf("token = %q, want t-abc", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
t.Errorf("request body missing credentials: %s", rt.gotBody)
if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct)
}
// client_secret_post: grant_type + client_id + client_secret in the form body.
for _, want := range []string{"grant_type=client_credentials", "client_id=cli_app", "client_secret=secret_x"} {
if !strings.Contains(rt.gotBody, want) {
t.Errorf("request body missing %q: %s", want, rt.gotBody)
}
}
}
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a
// deterministic client-side rejection that FetchTAT routes to
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
// typed error doResolveTAT (and thus every token-resolving command) returns.
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong
// secret → code 20002, unknown app → code 20048).
func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`}
hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 10003")
t.Fatal("expected error for invalid_client")
}
if token != "" {
t.Errorf("token = %q, want empty", token)
@@ -87,52 +96,115 @@ func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
}
// 10014 ("app secret invalid") — the most common real-world rejection (real
// app_id + wrong secret) — is globally mapped in codemeta to
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
}
}
// Any non-zero body code is a deterministic server-side rejection, so it
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
// caller still surfaces it rather than silently swallowing.
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
// Any other deterministic client-side OAuth error (e.g. invalid_scope) still
// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller
// surfaces it rather than silently swallowing it — but is NOT classified as a
// credential (invalid_client) problem.
func TestFetchTAT_OtherClientError_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 99999")
t.Fatal("expected error for invalid_scope")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err)
}
}
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
// silent.
// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to
// 0) must still surface as a non-nil typed error — never the ("", nil) success pair.
// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil
// for code 0, which would otherwise swallow this rejection into an empty-token success.
func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`}
hc := &http.Client{Transport: rt}
tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)")
}
if tok != "" {
t.Errorf("token = %q, want empty", tok)
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
}
// A gateway-style {code, msg} error (no OAuth error / error_description fields)
// must still surface its msg on the typed error, not degrade to a generic
// "API error: [code]". Guards the legacy-msg fallback in FetchTAT.
func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for {code, msg} response")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
if !strings.Contains(err.Error(), "app ticket invalid") {
t.Errorf("typed error must carry the Lark msg, got: %v", err)
}
}
// Transient server-side failures (5xx / server_error) are NOT deterministic
// credential rejections — they must stay UNTYPED so a probe caller treats them
// as upstream noise and stays silent (and retryers can back off).
func TestFetchTAT_ServerError_Untyped(t *testing.T) {
rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for server_error")
}
if errs.IsTyped(err) {
t.Errorf("server_error must be UNTYPED (transient), got typed %T %v", err, err)
}
}
// Rate-limiting is transient, not a deterministic credential rejection — an HTTP
// 429 (even with a parseable OAuth body) and the OAuth slow_down error must both
// stay UNTYPED so a rate-limited probe stays silent and retryers can back off.
func TestFetchTAT_RateLimit_Untyped(t *testing.T) {
cases := []struct {
name string
code int
body string
}{
{"http 429", 429, `{"code":99991400,"error":"too_many_requests","error_description":"rate limit exceeded"}`},
{"oauth slow_down", 200, `{"error":"slow_down","error_description":"polling too fast"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for rate-limit")
}
if errs.IsTyped(err) {
t.Errorf("rate-limit must be UNTYPED (transient), got typed %T %v", err, err)
}
})
}
}
// Non-2xx HTTP with a non-JSON body is ambiguous (not a structured OAuth
// rejection) — it must stay UNTYPED so a probe caller treats it as upstream
// noise and stays silent.
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
for _, code := range []int{401, 403, 500, 503} {
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
@@ -182,12 +254,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) {
brand core.LarkBrand
wantURL string
}{
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"},
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"},
}
for _, tc := range tests {
t.Run(string(tc.brand), func(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
t.Fatal(err)

View File

@@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{
// CategoryConfig
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
// CategoryPolicy
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},

View File

@@ -35,9 +35,12 @@ const (
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
// TAT-endpoint variant of the "wrong app credentials" condition.
// /open-apis/auth/v3/tenant_access_token/internal returns code 10014
// ("app secret invalid") instead of 99991543 when the secret is wrong.
// "Wrong app credentials" code from the LEGACY TAT endpoint
// (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret
// invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints
// TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client
// error, so it no longer emits 10014 itself; the constant + codemeta mapping
// are retained as a defensive fallback should 10014 still arrive.
LarkErrTATInvalidSecret = 10014
// Rate limit.

View File

@@ -47,6 +47,10 @@
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
},
"note": {
"en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" },
"zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" }
},
"sheets": {
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
"zh": { "title": "电子表格", "description": "电子表格操作" }

View File

@@ -7,7 +7,10 @@
// carrying their own copy.
package suggest
import "sort"
import (
"sort"
"strings"
)
// Levenshtein computes the classic edit distance between two strings. It is
// rune-aware, so it is correct for multi-byte input.
@@ -51,22 +54,29 @@ func Levenshtein(a, b string) int {
// signal of intent that raw edit distance misses.
func Closest(typed string, candidates []string, maxN int) []string {
type scored struct {
name string
prefix int
dist int
name string
contain bool
prefix int
dist int
}
limit := editLimit(typed)
ranked := make([]scored, 0, len(candidates))
for _, c := range candidates {
p := sharedPrefixLen(typed, c)
d := Levenshtein(typed, c)
// Keep only plausible matches: a meaningful shared prefix, or an edit
// distance within budget. Drop everything else so the hint stays short.
if p >= 3 || d <= limit {
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
ct := containsSegment(typed, c)
// Keep only plausible matches: a meaningful shared prefix, an edit
// distance within budget, or one name containing the other (a missing
// namespace prefix like "+block-list" vs "+base-block-list"). Drop
// everything else so the hint stays short.
if p >= 3 || d <= limit || ct {
ranked = append(ranked, scored{name: c, contain: ct, prefix: p, dist: d})
}
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].contain != ranked[j].contain {
return ranked[i].contain
}
if ranked[i].prefix != ranked[j].prefix {
return ranked[i].prefix > ranked[j].prefix
}
@@ -94,6 +104,21 @@ func editLimit(s string) int {
return 2
}
// containsSegment reports whether one name contains the other as a substring
// after stripping the "+"/"--" sigils. It catches hallucinated names that drop
// a namespace prefix (e.g. "+block-list" for "+base-block-list"), which share
// almost no prefix and sit far beyond the edit-distance budget. The shorter
// side must be at least 5 runes so generic fragments like "list" do not match
// half the catalog.
func containsSegment(a, b string) bool {
a = strings.TrimLeft(a, "+-")
b = strings.TrimLeft(b, "+-")
if len([]rune(a)) > len([]rune(b)) {
a, b = b, a
}
return len([]rune(a)) >= 5 && strings.Contains(b, a)
}
func sharedPrefixLen(a, b string) int {
ra, rb := []rune(a), []rune(b)
n := 0

View File

@@ -25,9 +25,11 @@ var migratedCommonHelperPaths = []string{
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/im/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/note/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",

View File

@@ -26,9 +26,11 @@ var migratedEnvelopePaths = []string{
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/im/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/note/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
@@ -36,7 +38,6 @@ var migratedEnvelopePaths = []string{
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
"shortcuts/im/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -953,6 +953,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
paths := []string{
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/drive/drive_search.go",
"shortcuts/im/im_messages_send.go",
"shortcuts/mail/mail_send.go",
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_progress_create.go",
@@ -988,6 +989,18 @@ common.` + helper + `()
}
}
func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) {
commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths))
for _, path := range migratedCommonHelperPaths {
commonPaths[path] = struct{}{}
}
for _, path := range migratedEnvelopePaths {
if _, ok := commonPaths[path]; !ok {
t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path)
}
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
src := `package calendar

View File

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

View File

@@ -13,12 +13,12 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAccessScopeGet reads the current access scope configuration of a Miaoda app.
// AppsAccessScopeGet reads the current access scope configuration of an app.
// 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。
var AppsAccessScopeGet = common.Shortcut{
Service: appsService,
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Description: "Get app access scope configuration",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +access-scope-get --app-id <app_id>",
@@ -39,7 +39,7 @@ var AppsAccessScopeGet = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Get Miaoda app access scope")
Desc("Get app access scope")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))

View File

@@ -24,7 +24,7 @@ var allowedAccessTargetTypes = map[string]bool{
var AppsAccessScopeSet = common.Shortcut{
Service: appsService,
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Description: "Set app access scope (specific / public / tenant)",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope tenant`,
@@ -52,7 +52,7 @@ var AppsAccessScopeSet = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
dry := common.NewDryRunAPI().
PUT(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Set Miaoda app access scope")
Desc("Set app access scope")
body, bodyErr := buildAccessScopeBody(rctx)
if bodyErr != nil {
dry.Set("body_error", bodyErr.Error())

View File

@@ -12,13 +12,13 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create Miaoda apps"
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
// AppsCreate creates a new Miaoda app.
// AppsCreate creates a new app.
var AppsCreate = common.Shortcut{
Service: appsService,
Command: "+create",
Description: "Create a new Miaoda app",
Description: "Create a new app",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
@@ -42,7 +42,7 @@ var AppsCreate = common.Shortcut{
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(apiBasePath + "/apps").
Desc("Create a Miaoda app").
Desc("Create an app").
Body(buildAppsCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -14,7 +14,7 @@ import (
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --env dev`"
// AppsDBEnvCreate creates a DB environment for a Miaoda app拆分单库为 dev/online 多环境)。
// AppsDBEnvCreate creates a DB environment for an app拆分单库为 dev/online 多环境)。
//
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
@@ -30,7 +30,7 @@ var AppsDBEnvCreate = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
},
@@ -42,7 +42,7 @@ var AppsDBEnvCreate = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appDbEnvCreatePath(appID)).
Desc("Create Miaoda app DB environment").
Desc("Create app DB environment").
Body(buildDBEnvCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -17,7 +17,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsDBExecute executes SQL against a Miaoda app database.
// AppsDBExecute executes SQL against an app database.
//
// POST /apps/{app_id}/sql_commandsCLI 永远带 ?transactional=false 进入 DBA 模式
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON
@@ -45,7 +45,7 @@ import (
var AppsDBExecute = common.Shortcut{
Service: appsService,
Command: "+db-execute",
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database",
Description: "Execute SQL (SELECT / DML / DDL) against an app database",
Risk: "high-risk-write",
Tips: []string{
`Example: lark-cli apps +db-execute --app-id <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
@@ -56,7 +56,7 @@ var AppsDBExecute = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
@@ -97,7 +97,7 @@ var AppsDBExecute = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appSQLPath(appID)).
Desc("Execute SQL on Miaoda app database").
Desc("Execute SQL on app database").
Params(buildDBSQLParams(rctx)).
Body(buildDBSQLBody(rctx))
},

View File

@@ -35,7 +35,7 @@ var AppsDBTableGet = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
},
@@ -52,7 +52,7 @@ var AppsDBTableGet = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appTablePath(appID, strings.TrimSpace(rctx.Str("table")))).
Desc("Get Miaoda app db table schema").
Desc("Get app db table schema").
Params(buildDBTableGetParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -15,7 +15,7 @@ import (
const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
// AppsDBTableList lists tables in a Miaoda app's database.
// AppsDBTableList lists tables in an app's database.
//
// GET /apps/{app_id}/tablescursor 分页response items[] 含 estimated_row_count /
// size_bytes optional 字段,默认返回,不必额外传 query。
@@ -29,7 +29,7 @@ const dbTableListHint = "verify --app-id is correct; if targeting --env dev, cre
var AppsDBTableList = common.Shortcut{
Service: appsService,
Command: "+db-table-list",
Description: "List tables in a Miaoda app database (cursor pagination)",
Description: "List tables in an app database (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-table-list --app-id <app_id>",
@@ -39,7 +39,7 @@ var AppsDBTableList = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
@@ -52,7 +52,7 @@ var AppsDBTableList = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appTablesPath(appID)).
Desc("List Miaoda app db tables").
Desc("List app db tables").
Params(buildDBTableListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -19,7 +19,7 @@ import (
var AppsHTMLPublish = common.Shortcut{
Service: appsService,
Command: "+html-publish",
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
Description: "Publish HTML to an app (single multipart POST returns the access URL)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./dist",
@@ -29,7 +29,7 @@ var AppsHTMLPublish = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "path", Desc: "path to HTML file or directory", Required: true},
{Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"},
},
@@ -179,7 +179,7 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
}
}
return appsFailedPreconditionParamError("--path", "--path is missing index.html").
WithHint("Miaoda uses index.html as the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
WithHint("index.html is the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
}
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {

View File

@@ -27,8 +27,8 @@ const defaultInitBranch = "sprint/default"
// the non-empty (`app sync`) path stays a single commit.
const (
commitMsgAppCode = "chore: initialize app project code"
commitMsgAppConfig = "chore: initialize miaoda app config"
commitMsgUpgrade = "chore: initialize miaoda app repository"
commitMsgAppConfig = "chore: initialize app config"
commitMsgUpgrade = "chore: initialize app repository"
)
// scaffold kinds returned by runScaffold and consumed by commitAndPushIfDirty.
@@ -49,11 +49,11 @@ const (
// can swap in a fakeCommandRunner. Production uses execCommandRunner.
var initRunner commandRunner = execCommandRunner{}
// AppsInit initializes a Miaoda app's code and local development environment.
// AppsInit initializes an app's code and local development environment.
var AppsInit = common.Shortcut{
Service: appsService,
Command: "+init",
Description: "Initialize a Miaoda app's code and local development environment",
Description: "Initialize an app's code and local development environment",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +init --app-id <app_id> --dir <dir>",
@@ -73,7 +73,7 @@ var AppsInit = common.Shortcut{
// envelope. The spec and the E2E assert exit-2 + a structured
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
// check lives in Validate (typed validation error -> exit 2).
{Name: "app-id", Desc: "Miaoda app ID"},
{Name: "app-id", Desc: "app ID"},
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
},
@@ -87,7 +87,7 @@ var AppsInit = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
template := resolveTemplate(rctx, appID)
dry := common.NewDryRunAPI().
Desc("Initialize Miaoda app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
Desc("Initialize app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
Set("checkout", "git checkout "+defaultInitBranch).
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
@@ -191,7 +191,7 @@ func ensureEmptyDir(dir string) error {
return nil
}
// isAlreadyInitialized reports whether dir is an already-initialized Miaoda app
// isAlreadyInitialized reports whether dir is an already-initialized app
// repo, detected by the presence of <dir>/.spark/meta.json (regardless of its
// app_id value). Used to short-circuit +init into a friendly no-op.
func isAlreadyInitialized(dir string) bool {
@@ -379,7 +379,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
}
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized Miaoda app repo -> skip clone/scaffold/commit, but still refresh
// initialized app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.
if isAlreadyInitialized(dir) {
initLogf(rctx, "Already initialized at %s — refreshing local environment", dir)
@@ -556,7 +556,7 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
// commitAndPushIfDirty commits and pushes only when the working tree has
// changes; a clean tree is a no-op (returns false,false). For the empty-repo
// init path (scaffoldKind == "init") it splits the scaffolded tree into two
// commits — app project code, then Miaoda config (.spark/.agent) — skipping
// commits — app project code, then app config (.spark/.agent) — skipping
// either commit when that group has no changes (no empty commits). Other paths
// commit once. Push is a single `git push origin <branch>` for all commits.
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
@@ -621,7 +621,7 @@ func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...strin
// classifyPorcelain parses `git status --porcelain` output and partitions the
// changed paths into the "app code" group (anything outside .spark/ and .agent/)
// and the "Miaoda config" group (.spark/ and .agent/). It returns the exact
// and the "app config" group (.spark/ and .agent/). It returns the exact
// porcelain paths so callers can stage them verbatim: porcelain never lists
// gitignored files, so `git add -- <these paths>` never trips git's ignored-path
// error. (Naming an ignored dir explicitly — or combining a "." pathspec with
@@ -658,7 +658,7 @@ func porcelainPath(line string) string {
return p
}
// isConfigPath reports whether p is the Miaoda app-config group: the .spark or
// isConfigPath reports whether p is the app-config group: the .spark or
// .agent directory itself, or anything under them. ".sparkrc" is NOT config.
func isConfigPath(p string) bool {
return p == ".spark" || p == ".agent" ||

View File

@@ -835,7 +835,7 @@ func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) {
t.Fatalf("unexpected: %v", err)
}
msgs := commitMessages(f.calls)
want := []string{"chore: initialize app project code", "chore: initialize miaoda app config"}
want := []string{"chore: initialize app project code", "chore: initialize app config"}
if len(msgs) != 2 || msgs[0] != want[0] || msgs[1] != want[1] {
t.Fatalf("commit messages = %v, want %v", msgs, want)
}
@@ -896,7 +896,7 @@ func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
t.Fatalf("unexpected: %v", err)
}
msgs := commitMessages(f.calls)
if len(msgs) != 1 || msgs[0] != "chore: initialize miaoda app config" {
if len(msgs) != 1 || msgs[0] != "chore: initialize app config" {
t.Fatalf("commit messages = %v, want one config commit", msgs)
}
}
@@ -916,7 +916,7 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
t.Fatalf("unexpected: %v", err)
}
msgs := commitMessages(f.calls)
if len(msgs) != 1 || msgs[0] != "chore: initialize miaoda app repository" {
if len(msgs) != 1 || msgs[0] != "chore: initialize app repository" {
t.Fatalf("commit messages = %v, want one upgrade commit", msgs)
}
for _, c := range f.calls {

View File

@@ -12,7 +12,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsList lists Miaoda apps visible to the calling user (cursor pagination).
// AppsList lists apps visible to the calling user (cursor pagination).
//
// Supports name fuzzy match (--keyword), ownership-dimension filter
// (--ownership: all / mine / shared), and app-type filter (--app-type). See
@@ -22,7 +22,7 @@ import (
var AppsList = common.Shortcut{
Service: appsService,
Command: "+list",
Description: "List Miaoda apps visible to the calling user (cursor pagination)",
Description: "List apps visible to the calling user (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +list",
@@ -42,7 +42,7 @@ var AppsList = common.Shortcut{
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(apiBasePath + "/apps").
Desc("List Miaoda apps").
Desc("List apps").
Params(buildAppsListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -13,11 +13,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsReleaseCreate creates a release for a Miaoda app.
// AppsReleaseCreate creates a release for an app.
var AppsReleaseCreate = common.Shortcut{
Service: appsService,
Command: "+release-create",
Description: "Create a release for a Miaoda app (returns release_id for status polling)",
Description: "Create a release for an app (returns release_id for status polling)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +release-create --app-id <app_id>",
@@ -27,7 +27,7 @@ var AppsReleaseCreate = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -26,7 +26,7 @@ var AppsReleaseGet = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -57,6 +57,9 @@ var AppsReleaseGet = common.Shortcut{
out := data
if release, ok := data["release"].(map[string]interface{}); ok {
out = release
if el, ok := data["error_logs"]; ok {
out["error_logs"] = el
}
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n",

View File

@@ -134,13 +134,15 @@ func TestAppsReleaseGetPrettyFailedErrorLogs(t *testing.T) {
URL: "/open-apis/spark/v1/apps/app_x/releases/6",
Body: map[string]interface{}{
"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "6", "status": "failed",
"created_at": "1700000000000", "updated_at": "1700000000050",
"data": map[string]interface{}{
"release": map[string]interface{}{
"release_id": "6", "status": "failed",
"created_at": "1700000000000", "updated_at": "1700000000050",
},
"error_logs": []interface{}{
map[string]interface{}{"step": "build", "error_log": "compile error"},
},
}},
},
},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
@@ -200,11 +202,13 @@ func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/9",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "9", "status": "failed",
"created_at": "1700000000000", "updated_at": "1700000000050",
"data": map[string]interface{}{
"release": map[string]interface{}{
"release_id": "9", "status": "failed",
"created_at": "1700000000000", "updated_at": "1700000000050",
},
"error_logs": []interface{}{},
}}},
}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
@@ -214,6 +218,69 @@ func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
}
}
func TestAppsReleaseGetJSONErrorLogsPassthrough(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "6")
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/6",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{
"release": map[string]interface{}{
"release_id": "6", "status": "failed",
"created_at": "1700000000000", "updated_at": "1700000000050",
},
"error_logs": []interface{}{
map[string]interface{}{"step": "build", "error_log": "compile error"},
},
}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
}
logs, ok := env.Data["error_logs"].([]interface{})
if !ok || len(logs) != 1 {
t.Fatalf("JSON must passthrough data.error_logs, got: %v", env.Data["error_logs"])
}
first, _ := logs[0].(map[string]interface{})
if first["step"] != "build" || first["error_log"] != "compile error" {
t.Errorf("error_logs content mismatch: %v", logs[0])
}
// flattened release fields must still be present alongside error_logs
if env.Data["release_id"] != "6" || env.Data["status"] != "failed" {
t.Errorf("flattened release fields missing: %v", env.Data)
}
}
func TestAppsReleaseGetJSONNoErrorLogsKeyWhenAbsent(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/5",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "5", "status": "finished",
"created_at": "1700000000000", "updated_at": "1700000000001",
}}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
}
if _, present := env.Data["error_logs"]; present {
t.Errorf("error_logs key must be absent when API omits it, got: %v", env.Data)
}
}
func TestAppsReleaseGetPrettyCommitID(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "10")
rctx.Format = "pretty"

View File

@@ -14,11 +14,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsReleaseList lists a Miaoda app's release history (most recent first).
// AppsReleaseList lists an app's release history (most recent first).
var AppsReleaseList = common.Shortcut{
Service: appsService,
Command: "+release-list",
Description: "List a Miaoda app's release history (most recent first)",
Description: "List an app's release history (most recent first)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +release-list --app-id <app_id>",
@@ -28,7 +28,7 @@ var AppsReleaseList = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "status", Enum: []string{"publishing", "finished", "failed"}, Desc: "filter by release status: publishing | finished | failed"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 500)"},
{Name: "page-token", Desc: "pagination cursor from a previous response"},

View File

@@ -13,11 +13,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsSessionCreate creates a new session under an existing Miaoda app.
// AppsSessionCreate creates a new session under an existing app.
var AppsSessionCreate = common.Shortcut{
Service: appsService,
Command: "+session-create",
Description: "Create a session under a Miaoda app",
Description: "Create a session under an app",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +session-create --app-id <app_id>",
@@ -37,7 +37,7 @@ var AppsSessionCreate = common.Shortcut{
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(sessionsPath(rctx.Str("app-id"))).
Desc("Create a session under a Miaoda app")
Desc("Create a session under an app")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", sessionsPath(rctx.Str("app-id")), nil, nil)

View File

@@ -12,11 +12,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsSessionList lists sessions under a Miaoda app (cursor pagination, single page).
// AppsSessionList lists sessions under an app (cursor pagination, single page).
var AppsSessionList = common.Shortcut{
Service: appsService,
Command: "+session-list",
Description: "List sessions under a Miaoda app (cursor pagination)",
Description: "List sessions under an app (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +session-list --app-id <app_id>",
@@ -39,7 +39,7 @@ var AppsSessionList = common.Shortcut{
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(sessionsPath(rctx.Str("app-id"))).
Desc("List sessions under a Miaoda app").
Desc("List sessions under an app").
Params(buildSessionListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -13,11 +13,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsUpdate partially updates a Miaoda app's name / description.
// AppsUpdate partially updates an app's name / description.
var AppsUpdate = common.Shortcut{
Service: appsService,
Command: "+update",
Description: "Partially update a Miaoda app (only provided fields are sent)",
Description: "Partially update an app (only provided fields are sent)",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +update --app-id <app_id> --name "新名称"`,
@@ -49,7 +49,7 @@ var AppsUpdate = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
PATCH(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Update a Miaoda app").
Desc("Update an app").
Body(buildAppsUpdateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -12,12 +12,12 @@ import (
// appsService 是 CLI 命令的 service 前缀lark-cli apps ...)。
const appsService = "apps"
// apiBasePath is the registered OAPI prefix for the Miaoda apps domain.
// apiBasePath is the registered OAPI prefix for the apps domain.
const apiBasePath = "/open-apis/spark/v1"
// appIDListHint is the shared recovery hint for commands whose most likely
// failure cause is a wrong/inaccessible --app-id. It points at +list to find
// the correct Miaoda app id. The app_/cli_ format rule is taught in
// the correct app id. The app_/cli_ format rule is taught in
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it.
const appIDListHint = "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`"

View File

@@ -35,12 +35,12 @@ const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
// gitCredentialIssueHint is the actionable next-step attached to a failed
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
const gitCredentialIssueHint = "failed to issue the Git credential: verify --app-id is correct and you have developer access to this Miaoda app; a 5xx is a transient server error and is safe to retry"
const gitCredentialIssueHint = "failed to issue the Git credential: verify --app-id is correct and you have developer access to this app; a 5xx is a transient server error and is safe to retry"
var AppsGitCredentialInit = common.Shortcut{
Service: appsService,
Command: "+git-credential-init",
Description: "Initialize Git credentials and a URL-scoped Git helper for a Miaoda app repository",
Description: "Initialize Git credentials and a URL-scoped Git helper for an app repository",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +git-credential-init --app-id <app_id>",
@@ -49,7 +49,7 @@ var AppsGitCredentialInit = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
@@ -64,7 +64,7 @@ var AppsGitCredentialInit = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(gitCredentialIssuePath).
Desc("Issue a Miaoda Git repository PAT").
Desc("Issue an app Git repository PAT").
Set("mode", "api-plus-local-setup").
Set("action", "initialize_local_git_credential").
Set("app_id", appID).
@@ -81,7 +81,7 @@ var AppsGitCredentialInit = common.Shortcut{
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, runtimeIssuer{rctx: rctx})
result, err := manager.Init(ctx, profileFromConfig(rctx.Config), appID)
if err != nil {
return gitCredentialLocalError("Initialize local Miaoda Git credential", err)
return gitCredentialLocalError("Initialize local app Git credential", err)
}
payload := map[string]interface{}{
"app_id": result.AppID,
@@ -119,7 +119,7 @@ var AppsGitCredentialInit = common.Shortcut{
var AppsGitCredentialRemove = common.Shortcut{
Service: appsService,
Command: "+git-credential-remove",
Description: "Remove local Git credentials and the URL-scoped Git helper for a Miaoda app repository",
Description: "Remove local Git credentials and the URL-scoped Git helper for an app repository",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +git-credential-remove --app-id <app_id>",
@@ -128,7 +128,7 @@ var AppsGitCredentialRemove = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
@@ -159,7 +159,7 @@ var AppsGitCredentialRemove = common.Shortcut{
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil)
result, err := manager.Remove(ctx, profileFromConfig(rctx.Config), appID)
if err != nil {
return gitCredentialLocalError("Remove local Miaoda Git credential", err)
return gitCredentialLocalError("Remove local app Git credential", err)
}
payload := map[string]interface{}{
"app_id": result.AppID,
@@ -193,7 +193,7 @@ var AppsGitCredentialRemove = common.Shortcut{
var AppsGitCredentialList = common.Shortcut{
Service: appsService,
Command: "+git-credential-list",
Description: "List local Git credentials for Miaoda app repositories",
Description: "List local Git credentials for app repositories",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +git-credential-list",
@@ -215,7 +215,7 @@ var AppsGitCredentialList = common.Shortcut{
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now)
if err != nil {
return gitCredentialLocalError("List local Miaoda Git credentials", err)
return gitCredentialLocalError("List local app Git credentials", err)
}
payload := map[string]interface{}{
"count": len(records),
@@ -252,7 +252,7 @@ func InstallOnApps(parent *cobra.Command, f *cmdutil.Factory) {
func newGitCredentialHelperCommand(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "git-credential-helper get|store|erase",
Short: "Git credential helper for Miaoda app repositories",
Short: "Git credential helper for app repositories",
Hidden: true,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -260,7 +260,7 @@ func newGitCredentialHelperCommand(f *cmdutil.Factory) *cobra.Command {
return runGitCredentialHelper(cmd.Context(), f, strings.TrimSpace(appID), args[0])
},
}
cmd.Flags().String("app-id", "", "Miaoda app ID")
cmd.Flags().String("app-id", "", "app ID")
_ = cmd.Flags().MarkHidden("app-id")
return cmd
}
@@ -457,10 +457,10 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
issued.AppID = appID
}
if issued.GitHTTPURL == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing gitURL")
}
if issued.PAT == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token")
}
return issued, nil
}
@@ -479,7 +479,7 @@ func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.Cla
detail := logIDDetail(resp)
if resp == nil || len(resp.RawBody) == 0 {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"Issue Miaoda Git credential: empty response body")
"Issue app Git credential: empty response body")
}
var result map[string]any
jsonErr := json.Unmarshal(resp.RawBody, &result)
@@ -522,7 +522,7 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
if message == "" {
message = "Git credential API returned non-zero BaseResp status"
}
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue Miaoda Git credential: %s", message).WithCode(int(code))
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code))
if logID != "" {
baseErr = baseErr.WithLogID(logID)
}

View File

@@ -699,7 +699,7 @@ func assertStringSliceEqual(t *testing.T, got, want []string) {
func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) {
plain := errors.New("git config failed")
wrapped := gitCredentialLocalError("List local Miaoda Git credentials", plain)
wrapped := gitCredentialLocalError("List local app Git credentials", plain)
var configErr *errs.ConfigError
if !errors.As(wrapped, &configErr) {
t.Fatalf("plain local error wrapped as %T, want *errs.ConfigError", wrapped)

View File

@@ -458,19 +458,19 @@ func defaultUsername(username string) string {
func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error {
if issued == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: empty credential")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: empty credential")
}
if issued.AppID != "" && issued.AppID != appID {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
}
if normalizedURL == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing gitURL")
}
if strings.TrimSpace(issued.PAT) == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token")
}
if issued.ExpiresAt <= now {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response expiredTime must be in the future")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response expiredTime must be in the future")
}
return nil
}

View File

@@ -27,7 +27,7 @@ const (
)
// CredentialFile is the app-scoped non-secret metadata persisted under the
// Miaoda app storage directory.
// app storage directory.
type CredentialFile struct {
Version int `json:"version"`
CredentialRecord

View File

@@ -53,7 +53,7 @@ func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tar
return &htmlPublishResponse{URL: url}, nil
}
// OAPI business error codes returned by the Miaoda
// OAPI business error codes returned by the
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
// service; update when new codes are documented in the OAPI spec.
const (
@@ -66,7 +66,7 @@ func buildHTMLPublishFailureHint(code int) string {
case errCodeBuildFailed:
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
case errCodeAppNotFound:
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the Miaoda app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
default:
return ""
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -20,6 +21,7 @@ var BaseDataQuery = common.Shortcut{
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "table-id", Hidden: true},
{Name: "dsl", Desc: "query JSON DSL; read lark-base-data-query-guide.md first, then lark-base-data-query.md for the full DSL SSOT", Required: true},
},
Tips: []string{
@@ -28,6 +30,9 @@ var BaseDataQuery = common.Shortcut{
"`dimensions` and `measures` cannot both be empty.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("table-id")) != "" {
return baseFlagErrorf("+data-query does not support --table-id; put table names/fields inside --dsl (read lark-base-data-query-guide.md)")
}
var dsl map[string]interface{}
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
dec.UseNumber()

View File

@@ -73,6 +73,14 @@ func TestDryRunFieldOps(t *testing.T) {
)
assertDryRunContains(t, dryRunFieldList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "offset=0", "limit=200")
batchListRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x"},
map[string][]string{"table-id": {"tbl_1", "tbl_2"}},
nil,
map[string]int{"offset": 0, "limit": 50},
)
assertDryRunContains(t, dryRunFieldListBatch(ctx, batchListRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "GET /open-apis/base/v3/bases/app_x/tables/tbl_2/fields", "limit=50")
rt := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",

View File

@@ -1066,6 +1066,129 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
}
})
t.Run("list resolves table name", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"tables": []interface{}{
map[string]interface{}{"id": "tbl_orders", "name": "Orders"},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_orders/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_name", "name": "Name", "type": "text"},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "Orders"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"name": "Name"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list batch multiple tables", 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_a/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_a", "name": "Name", "type": "text", "style": map[string]interface{}{"type": "plain"}},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_b/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_b", "name": "Status", "type": "select", "options": []interface{}{map[string]interface{}{"name": "Todo", "color": "red"}}},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_a", "--table-id", "tbl_b", "--compact"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"tables"`) || !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_b"`) || !strings.Contains(got, `"options": [`) || !strings.Contains(got, `"Todo"`) || !strings.Contains(got, `"style"`) || strings.Contains(got, `"color"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list batch resolves table names", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"tables": []interface{}{
map[string]interface{}{"id": "tbl_orders", "name": "Orders"},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_a/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_a", "name": "Name", "type": "text"},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_orders/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_order", "name": "Status", "type": "select"},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_a", "--table-id", "Orders"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_orders"`) || !strings.Contains(got, `"table_ref": "Orders"`) || !strings.Contains(got, `"table_name": "Orders"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list batch default keeps full 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_b/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_b", "name": "Status", "type": "select", "options": []interface{}{map[string]interface{}{"name": "Todo", "color": "red"}}},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_b"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"table_id": "tbl_b"`) || !strings.Contains(got, `"color": "red"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("get", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1434,6 +1557,48 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("search accepts query alias", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title"},
"field_id_list": []interface{}{"fld_title"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Created by AI"}},
"has_more": false,
},
},
}
reg.Register(searchStub)
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--query", "Created",
"--search-field", "Title",
"--format", "json",
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody))
}
if body["keyword"] != "Created" {
t.Fatalf("captured body=%#v", body)
}
})
t.Run("search with filter json file", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
tmp := t.TempDir()
@@ -1525,20 +1690,29 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Run("list fields alias projects columns", 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?field_id=Name&limit=100&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
})
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
t.Run("list fields alias works in dry-run", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "field_id=Name") {
t.Fatalf("stdout=%s", got)
}
})
t.Run("get", func(t *testing.T) {

View File

@@ -59,6 +59,23 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
return &common.RuntimeContext{Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}}
}
func TestFieldSearchOptionsAlias(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"field-name": "Status"}, nil, nil)
if got := fieldSearchOptionsRef(runtime); got != "Status" {
t.Fatalf("field ref=%q", got)
}
if err := BaseFieldSearchOptions.Validate(context.Background(), runtime); err != nil {
t.Fatalf("err=%v", err)
}
}
func TestFieldSearchOptionsRequiresFieldRef(t *testing.T) {
err := BaseFieldSearchOptions.Validate(context.Background(), newBaseTestRuntime(map[string]string{}, nil, nil))
if err == nil || !strings.Contains(err.Error(), "--field-id is required") {
t.Fatalf("err=%v", err)
}
}
func TestBaseAction(t *testing.T) {
t.Run("missing action", func(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": false}, nil)
@@ -135,7 +152,7 @@ func TestShortcutsCatalog(t *testing.T) {
want := []string{
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+field-list", "+field-list-batch", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
"+record-history-list",
@@ -1088,6 +1105,22 @@ func TestBaseRecordValidate(t *testing.T) {
)); err != nil {
t.Fatalf("record search flag validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "query": "Alice"},
map[string][]string{"search-field": {"Name"}},
nil,
nil,
)); err != nil {
t.Fatalf("record search query alias validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice", "query": "Bob"},
map[string][]string{"search-field": {"Name"}},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "use only one") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",

View File

@@ -19,6 +19,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "dashboard-id", Hidden: true},
blockIDFlag(true),
},
Tips: []string{

View File

@@ -21,6 +21,7 @@ var BaseFieldList = common.Shortcut{
tableRefFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
{Name: "compact", Type: "bool", Desc: "return compact field objects (id/name/type/style/options) for lower context cost; default returns full field objects"},
},
DryRun: dryRunFieldList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseFieldListBatch = common.Shortcut{
Service: "base",
Command: "+field-list-batch",
Description: "List fields for multiple tables in one call",
Risk: "read",
Scopes: []string{"base:field:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "table-id", Type: "string_array", Desc: tableRefFlag(true).Desc + "; repeat to list fields for multiple tables", Required: true},
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
{Name: "compact", Type: "bool", Desc: "return compact field objects (id/name/type/style/options) for lower context cost; default returns full field objects"},
},
DryRun: dryRunFieldListBatch,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFieldListBatch(runtime)
},
}

View File

@@ -10,6 +10,12 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
type fieldListTableRef struct {
input string
id string
name string
}
func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
offset := runtime.Int("offset")
if offset < 0 {
@@ -23,6 +29,22 @@ func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.
Set("table_id", baseTableID(runtime))
}
func dryRunFieldListBatch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
offset := runtime.Int("offset")
if offset < 0 {
offset = 0
}
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
dry := common.NewDryRunAPI()
for _, tableIDValue := range runtime.StrArray("table-id") {
dry.GET(baseV3Path("bases", runtime.Str("base-token"), "tables", tableIDValue, "fields")).
Params(map[string]interface{}{"offset": offset, "limit": limit}).
Set("base_token", runtime.Str("base-token")).
Set("table_id", tableIDValue)
}
return dry
}
func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
@@ -61,6 +83,7 @@ func dryRunFieldDelete(_ context.Context, runtime *common.RuntimeContext) *commo
}
func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fieldRef := fieldSearchOptionsRef(runtime)
params := map[string]interface{}{
"offset": runtime.Int("offset"),
"limit": runtime.Int("limit"),
@@ -68,15 +91,15 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
if params["limit"].(int) <= 0 {
params["limit"] = 30
}
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
if keyword := strings.TrimSpace(fieldSearchOptionsKeyword(runtime)); keyword != "" {
params["query"] = keyword
}
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id/options").
GET(baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields", fieldRef, "options")).
Params(params).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
Set("field_id", runtime.Str("field-id"))
Set("field_id", fieldRef)
}
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
@@ -118,17 +141,142 @@ func executeFieldList(runtime *common.RuntimeContext) error {
offset = 0
}
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit)
baseToken := runtime.Str("base-token")
tableRef, err := resolveFieldListTableRefs(runtime, baseToken, []string{baseTableID(runtime)})
if err != nil {
return err
}
fields, total, err := listAllFields(runtime, baseToken, tableRef[0].id, offset, limit)
if err != nil {
return err
}
if total == 0 {
total = len(fields)
}
if runtime.Bool("compact") {
fields = compactFields(fields)
}
runtime.Out(map[string]interface{}{"fields": fields, "total": total}, nil)
return nil
}
func executeFieldListBatch(runtime *common.RuntimeContext) error {
offset := runtime.Int("offset")
if offset < 0 {
offset = 0
}
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
baseToken := runtime.Str("base-token")
tableRefs, err := resolveFieldListTableRefs(runtime, baseToken, runtime.StrArray("table-id"))
if err != nil {
return err
}
results := make([]map[string]interface{}, 0, len(tableRefs))
for _, tableRef := range tableRefs {
fields, total, err := listAllFields(runtime, baseToken, tableRef.id, offset, limit)
if err != nil {
return err
}
if total == 0 {
total = len(fields)
}
if runtime.Bool("compact") {
fields = compactFields(fields)
}
result := map[string]interface{}{
"table_id": tableRef.id,
"fields": fields,
"total": total,
}
if tableRef.input != tableRef.id {
result["table_ref"] = tableRef.input
}
if tableRef.name != "" {
result["table_name"] = tableRef.name
}
results = append(results, result)
}
runtime.Out(map[string]interface{}{"tables": results, "total": len(results)}, nil)
return nil
}
func resolveFieldListTableRefs(runtime *common.RuntimeContext, baseToken string, refs []string) ([]fieldListTableRef, error) {
if len(refs) == 0 {
return nil, baseValidationErrorf("--table-id is required")
}
resolved := make([]fieldListTableRef, 0, len(refs))
needsTableList := false
for _, raw := range refs {
ref := strings.TrimSpace(raw)
if ref == "" {
return nil, baseValidationErrorf("--table-id must not be empty")
}
if !isBaseTableID(ref) {
needsTableList = true
}
resolved = append(resolved, fieldListTableRef{input: ref, id: ref})
}
if !needsTableList {
return resolved, nil
}
tables, err := listEveryTable(runtime, baseToken)
if err != nil {
return nil, err
}
for i, tableRef := range resolved {
if isBaseTableID(tableRef.input) {
continue
}
table, err := resolveTableRef(tables, tableRef.input)
if err != nil {
return nil, baseValidationErrorf("table %q not found; run +table-list to verify the table name or pass the tbl... ID", tableRef.input)
}
tableIDValue := tableID(table)
if tableIDValue == "" {
return nil, baseValidationErrorf("table %q resolved without a table ID; run +table-list and pass the tbl... ID", tableRef.input)
}
resolved[i].id = tableIDValue
resolved[i].name = tableNameFromMap(table)
}
return resolved, nil
}
func isBaseTableID(ref string) bool {
return strings.HasPrefix(strings.TrimSpace(ref), "tbl")
}
// compactFields projects each field to the keys an agent needs for selection
// (id / name / type / style, plus select option names), dropping formula
// expressions and lookup internals that bloat agent context. Opt-in via
// `--compact`; the default output keeps full field objects.
func compactFields(fields []map[string]interface{}) []map[string]interface{} {
keep := []string{"id", "name", "type", "is_primary", "ui_type", "description", "style"}
out := make([]map[string]interface{}, 0, len(fields))
for _, f := range fields {
c := map[string]interface{}{}
for _, k := range keep {
if v, ok := f[k]; ok {
c[k] = v
}
}
if opts, ok := f["options"].([]interface{}); ok && len(opts) > 0 {
names := make([]interface{}, 0, len(opts))
for _, o := range opts {
if om, ok := o.(map[string]interface{}); ok {
if name, ok := om["name"]; ok {
names = append(names, name)
continue
}
}
names = append(names, o)
}
c["options"] = names
}
out = append(out, c)
}
return out
}
func executeFieldGet(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)
@@ -184,10 +332,25 @@ func executeFieldDelete(runtime *common.RuntimeContext) error {
return nil
}
func fieldSearchOptionsRef(runtime *common.RuntimeContext) string {
fieldRef := runtime.Str("field-id")
if strings.TrimSpace(fieldRef) == "" {
fieldRef = runtime.Str("field-name")
}
return fieldRef
}
func fieldSearchOptionsKeyword(runtime *common.RuntimeContext) string {
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
return keyword
}
return strings.TrimSpace(runtime.Str("query"))
}
func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)
fieldRef := runtime.Str("field-id")
fieldRef := fieldSearchOptionsRef(runtime)
params := map[string]interface{}{
"offset": runtime.Int("offset"),
"limit": runtime.Int("limit"),
@@ -195,7 +358,7 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
if params["limit"].(int) <= 0 {
params["limit"] = 30
}
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
if keyword := strings.TrimSpace(fieldSearchOptionsKeyword(runtime)); keyword != "" {
params["query"] = keyword
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef, "options"), params, nil)
@@ -210,7 +373,7 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
runtime.Out(map[string]interface{}{
"field_id": fieldRef,
"field_name": fieldRef,
"keyword": strings.TrimSpace(runtime.Str("keyword")),
"keyword": fieldSearchOptionsKeyword(runtime),
"options": options,
"total": total,
}, nil)

View File

@@ -5,6 +5,7 @@ package base
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -19,8 +20,10 @@ var BaseFieldSearchOptions = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
fieldRefFlag(true),
fieldRefFlag(false),
{Name: "field-name", Hidden: true},
{Name: "keyword", Desc: "keyword for option query"},
{Name: "query", Hidden: true},
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"},
},
@@ -29,6 +32,15 @@ var BaseFieldSearchOptions = common.Shortcut{
"Use only for fields with options, such as select or multi-select fields.",
},
DryRun: dryRunFieldSearchOptions,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(fieldSearchOptionsRef(runtime)) == "" {
return baseFlagErrorf("--field-id is required")
}
if strings.TrimSpace(runtime.Str("keyword")) != "" && strings.TrimSpace(runtime.Str("query")) != "" {
return baseFlagErrorf("--query is a deprecated alias for --keyword; use only one")
}
return nil
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFieldSearchOptions(runtime)
},

View File

@@ -4,6 +4,7 @@
package base
import (
"context"
"encoding/json"
"os"
"reflect"
@@ -496,3 +497,61 @@ func TestCanonicalSelectAndCompareHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
}
func TestNormalizePluralReferenceValues(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{"repeated single values", []string{"fldA", "fldB"}, []string{"fldA", "fldB"}},
{"json array", []string{`["fldA","fldB"]`}, []string{"fldA", "fldB"}},
{"comma separated ids", []string{"fldA, fldB"}, []string{"fldA", "fldB"}},
{"comma separated names", []string{"商品名称,SKU,单价"}, []string{"商品名称", "SKU", "单价"}},
{"trailing comma ignored", []string{"recA,recB,"}, []string{"recA", "recB"}},
{"fullwidth comma kept whole", []string{"销售额,单价"}, []string{"销售额,单价"}},
{"mixed forms", []string{`["fldA"]`, "fldB,fldC", "Name"}, []string{"fldA", "fldB", "fldC", "Name"}},
{"invalid json kept literal", []string{`[fldA`}, []string{`[fldA`}},
{"blank dropped", []string{" ", "fldA"}, []string{"fldA"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizePluralReferenceValues(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("got=%v want=%v", got, tc.want)
}
})
}
}
func TestRecordFlagAliasMergeAndDedupe(t *testing.T) {
fieldRT := newBaseTestRuntimeWithArrays(nil, map[string][]string{
"field-id": {"fldA"},
"fields": {"fldA,fldB"},
}, nil, nil)
if got := recordFieldFlags(fieldRT); !reflect.DeepEqual(got, []string{"fldA", "fldB"}) {
t.Fatalf("field flags=%v", got)
}
recordRT := newBaseTestRuntimeWithArrays(nil, map[string][]string{
"record-id": {"recA"},
"record-ids": {`["recA","recB"]`},
}, nil, nil)
if got := recordIDFlags(recordRT); !reflect.DeepEqual(got, []string{"recA", "recB"}) {
t.Fatalf("record flags=%v", got)
}
}
func TestFieldSearchOptionsKeywordQueryAlias(t *testing.T) {
ctx := context.Background()
if err := BaseFieldSearchOptions.Validate(ctx, newBaseTestRuntime(
map[string]string{"field-id": "Status", "keyword": "A", "query": "B"}, nil, nil,
)); err == nil || !strings.Contains(err.Error(), "use only one") {
t.Fatalf("err=%v", err)
}
queryOnly := newBaseTestRuntime(map[string]string{"field-id": "Status", "query": "Do"}, nil, nil)
if err := BaseFieldSearchOptions.Validate(ctx, queryOnly); err != nil {
t.Fatalf("err=%v", err)
}
if got := fieldSearchOptionsKeyword(queryOnly); got != "Do" {
t.Fatalf("keyword=%q", got)
}
}

View File

@@ -21,7 +21,10 @@ var BaseRecordGet = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "record-ids", Type: "string_array", Hidden: true},
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
{Name: "field-names", Type: "string_array", Hidden: true},
{Name: "fields", Type: "string_array", Hidden: true},
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
recordReadFormatFlag(),
},

View File

@@ -5,6 +5,7 @@ package base
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
@@ -21,9 +22,14 @@ var BaseRecordList = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
recordListFieldRefFlag(),
{Name: "field-names", Type: "string_array", Hidden: true},
{Name: "fields", Type: "string_array", Hidden: true},
recordListViewRefFlag(),
recordFilterFlag(),
recordFilterAliasFlag(),
recordSortFlag(),
recordSortAliasFlag(),
{Name: "json", Hidden: true},
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
recordReadFormatFlag(),
@@ -45,6 +51,9 @@ var BaseRecordList = common.Shortcut{
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("json")) != "" {
return baseFlagErrorf("+record-list does not support --json; use --filter-json for filters and --sort-json for sorting")
}
return validateRecordQueryOptions(runtime)
},
DryRun: dryRunRecordList,

View File

@@ -5,6 +5,7 @@ package base
import (
"context"
"encoding/json"
"net/url"
"strconv"
"strings"
@@ -45,11 +46,11 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
}
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
recordIDs := runtime.StrArray("record-id")
fieldIDs := runtime.StrArray("field-id")
recordIDs := recordIDFlags(runtime)
fieldIDs := recordFieldFlags(runtime)
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
return recordSelection{}, baseFlagErrorf("--record-id/--record-ids and --json are mutually exclusive")
}
if jsonRaw != "" {
pc := newParseCtx(runtime)
@@ -145,6 +146,73 @@ func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
})
}
func recordIDFlags(runtime *common.RuntimeContext) []string {
return mergeReferenceSources(
runtime.StrArray("record-id"),
normalizePluralReferenceValues(runtime.StrArray("record-ids")),
)
}
func recordFieldFlags(runtime *common.RuntimeContext) []string {
return mergeReferenceSources(
runtime.StrArray("field-id"),
normalizePluralReferenceValues(runtime.StrArray("field-names")),
normalizePluralReferenceValues(runtime.StrArray("fields")),
)
}
// mergeReferenceSources concatenates flag sources, dropping values from later
// sources that an earlier source already provided — so the same reference
// passed through both a canonical flag and its plural alias is sent only once.
// Duplicates inside a single source are kept on purpose: repeating a value on
// one flag is a user mistake that downstream validation should keep rejecting.
func mergeReferenceSources(sources ...[]string) []string {
var out []string
seenBefore := map[string]struct{}{}
for _, source := range sources {
for _, value := range source {
if _, ok := seenBefore[value]; ok {
continue
}
out = append(out, value)
}
for _, value := range source {
seenBefore[value] = struct{}{}
}
}
return out
}
// normalizePluralReferenceValues expands each raw value of a plural alias flag
// (--field-names / --fields / --record-ids) into individual references. Plural
// flags carry list semantics, so an ASCII comma is always a separator (eval
// traces show comma-joined values are exclusively lists, mostly field names);
// a JSON string array is also accepted. Names that contain a literal ASCII
// comma must use the singular flag (--field-id), which never splits. Fullwidth
// "" and "、" are untouched, so ordinary Chinese names are safe here too.
func normalizePluralReferenceValues(values []string) []string {
var out []string
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if strings.HasPrefix(value, "[") {
var parsed []string
if err := json.Unmarshal([]byte(value), &parsed); err == nil {
out = append(out, parsed...)
continue
}
}
for _, part := range strings.Split(value, ",") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
}
return out
}
func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([]string, error) {
var rawItems []interface{}
switch typed := values.(type) {
@@ -375,7 +443,7 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
}
func recordListFields(runtime *common.RuntimeContext) []string {
return runtime.StrArray("field-id")
return recordFieldFlags(runtime)
}
func executeRecordList(runtime *common.RuntimeContext) error {

View File

@@ -26,6 +26,10 @@ func recordFilterFlag() common.Flag {
}
}
func recordFilterAliasFlag() common.Flag {
return common.Flag{Name: "filter", Hidden: true, Input: []string{common.File}}
}
func recordSortFlag() common.Flag {
return common.Flag{
Name: recordSortJSONFlag,
@@ -34,6 +38,10 @@ func recordSortFlag() common.Flag {
}
}
func recordSortAliasFlag() common.Flag {
return common.Flag{Name: "sort", Hidden: true, Input: []string{common.File}}
}
func validateRecordQueryOptions(runtime *common.RuntimeContext) error {
if _, err := parseRecordFilterFlag(runtime); err != nil {
return err
@@ -43,7 +51,10 @@ func validateRecordQueryOptions(runtime *common.RuntimeContext) error {
}
func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) {
filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag))
filterRaw, err := recordQueryFlagValue(runtime, recordFilterJSONFlag, "filter")
if err != nil {
return nil, err
}
if filterRaw == "" {
return nil, nil
}
@@ -52,7 +63,10 @@ func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error)
}
func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag))
sortRaw, err := recordQueryFlagValue(runtime, recordSortJSONFlag, "sort")
if err != nil {
return nil, err
}
if sortRaw == "" {
return nil, nil
}
@@ -64,6 +78,18 @@ func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error)
return normalizeRecordSortValue(value, "--"+recordSortJSONFlag)
}
func recordQueryFlagValue(runtime *common.RuntimeContext, canonical string, alias string) (string, error) {
canonicalRaw := strings.TrimSpace(runtime.Str(canonical))
aliasRaw := strings.TrimSpace(runtime.Str(alias))
if canonicalRaw != "" && aliasRaw != "" {
return "", baseFlagErrorf("--%s is a deprecated alias for --%s; use only one", alias, canonical)
}
if canonicalRaw != "" {
return canonicalRaw, nil
}
return aliasRaw, nil
}
func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) {
var sortConfig []interface{}
if parsed, ok := value.([]interface{}); ok {
@@ -167,7 +193,7 @@ func applyRecordQueryToBody(runtime *common.RuntimeContext, body map[string]inte
func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := map[string]interface{}{}
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
if keyword := recordSearchKeyword(runtime); keyword != "" {
body["keyword"] = keyword
}
searchFields := runtime.StrArray("search-field")
@@ -217,6 +243,9 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("keyword")) != "" && strings.TrimSpace(runtime.Str("query")) != "" {
return baseFlagErrorf("--query is a deprecated alias for --keyword; use only one")
}
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" {
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
@@ -225,7 +254,7 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
_, err := recordSearchJSONBody(runtime)
return err
}
if strings.TrimSpace(runtime.Str("keyword")) == "" {
if recordSearchKeyword(runtime) == "" {
return baseFlagErrorf("--keyword is required unless --json is used")
}
if len(runtime.StrArray("search-field")) == 0 {
@@ -235,7 +264,7 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
}
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
return recordSearchKeyword(runtime) != "" ||
len(runtime.StrArray("search-field")) > 0 ||
len(recordListFields(runtime)) > 0 ||
runtime.Str("view-id") != "" ||
@@ -243,6 +272,13 @@ func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool
runtime.Changed("limit")
}
func recordSearchKeyword(runtime *common.RuntimeContext) string {
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
return keyword
}
return strings.TrimSpace(runtime.Str("query"))
}
func formatRecordQueryPriorityTip() string {
return fmt.Sprintf("Query priority: --%s overrides --view-id's view filter JSON; --%s overrides --view-id's view sort config.", recordFilterJSONFlag, recordSortJSONFlag)
}

View File

@@ -22,11 +22,16 @@ var BaseRecordSearch = common.Shortcut{
tableRefFlag(true),
{Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
{Name: "query", Desc: "deprecated alias for --keyword", Hidden: true},
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
recordListFieldRefFlag(),
{Name: "field-names", Type: "string_array", Hidden: true},
{Name: "fields", Type: "string_array", Hidden: true},
recordListViewRefFlag(),
recordFilterFlag(),
recordFilterAliasFlag(),
recordSortFlag(),
recordSortAliasFlag(),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"},
recordReadFormatFlag(),

View File

@@ -19,6 +19,7 @@ func Shortcuts() []common.Shortcut {
BaseTableUpdate,
BaseTableDelete,
BaseFieldList,
BaseFieldListBatch,
BaseFieldGet,
BaseFieldCreate,
BaseFieldUpdate,

View File

@@ -63,7 +63,8 @@ func executeTableList(runtime *common.RuntimeContext) error {
offset = 0
}
limit := common.ParseIntBounded(runtime, "limit", 1, 100)
tables, total, err := listAllTables(runtime, runtime.Str("base-token"), offset, limit)
baseToken := runtime.Str("base-token")
tables, total, err := listAllTables(runtime, baseToken, offset, limit)
if err != nil {
return err
}
@@ -186,6 +187,24 @@ func listEveryField(runtime *common.RuntimeContext, baseToken, tableID string) (
return items, nil
}
func listEveryTable(runtime *common.RuntimeContext, baseToken string) ([]map[string]interface{}, error) {
const pageLimit = 100
offset := 0
items := []map[string]interface{}{}
for {
batch, total, err := listAllTables(runtime, baseToken, offset, pageLimit)
if err != nil {
return nil, err
}
items = append(items, batch...)
if len(batch) == 0 || len(batch) < pageLimit || (total > 0 && len(items) >= total) {
break
}
offset += len(batch)
}
return items, nil
}
func listEveryView(runtime *common.RuntimeContext, baseToken, tableID string) ([]map[string]interface{}, error) {
const pageLimit = 100
offset := 0

View File

@@ -31,7 +31,10 @@ var DriveImport = common.Shortcut{
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); verify the returned verification_token, not the import task token"},
},
Tips: []string{
"When --target-token is set, data is mounted into that existing Base; verify output.verification_token with lark-cli base +base-get.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveImportSpec(driveImportSpec{
@@ -139,6 +142,14 @@ var DriveImport = common.Shortcut{
if status.Extra != nil {
out["extra"] = status.Extra
}
if spec.TargetToken != "" {
out["target_token"] = spec.TargetToken
out["verification_token"] = spec.TargetToken
if u := common.BuildResourceURL(runtime.Config.Brand, "bitable", spec.TargetToken); u != "" {
out["verification_url"] = u
}
out["verify_hint"] = fmt.Sprintf("because --target-token was used, verify the existing target Base with: lark-cli base +base-get --base-token %s", spec.TargetToken)
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)

View File

@@ -762,3 +762,43 @@ func TestDriveImportFallbackURLForSlides(t *testing.T) {
t.Fatalf("data.url = %#v, want %q (slides fallback)", got, want)
}
}
func TestDriveImportTargetTokenOutputsVerificationToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("target-token"))
driveImportMockEnv(t, reg, "ticket_target", map[string]interface{}{
"token": "bascn_backend_result",
"type": "bitable",
"job_status": float64(0),
})
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir: %v", err)
}
defer os.Chdir(origDir)
if err := os.WriteFile("snapshot.base", []byte("fake-base"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := mountAndRunDrive(t, DriveImport, []string{
"+import", "--file", "snapshot.base", "--type", "bitable", "--target-token", "bascn_target", "--as", "user",
}, f, stdout); err != nil {
t.Fatalf("import should succeed, got: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["token"], "bascn_backend_result"; got != want {
t.Fatalf("data.token = %#v, want backend result token %q", got, want)
}
if got, want := data["verification_token"], "bascn_target"; got != want {
t.Fatalf("data.verification_token = %#v, want target token %q", got, want)
}
if got, want := data["target_token"], "bascn_target"; got != want {
t.Fatalf("data.target_token = %#v, want target token %q", got, want)
}
hint, _ := data["verify_hint"].(string)
if !strings.Contains(hint, "lark-cli base +base-get --base-token bascn_target") {
t.Fatalf("verify_hint = %q, want target-token verification command", hint)
}
}

View File

@@ -841,7 +841,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
"page-size": "10",
}, nil)
got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":10`) {
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":"10"`) {
t.Fatalf("ImThreadsMessagesList.DryRun() = %s", got)
}
})
@@ -901,7 +901,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
"sort": "create_time",
}, nil)
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {

View File

@@ -48,7 +48,8 @@ var ImChatList = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
@@ -266,9 +267,16 @@ func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) {
// CSV string already normalized + bot-stripped by resolveTypes; pass "" to
// omit the types query param entirely (backward compatible default).
func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string) map[string]interface{} {
sortType := map[string]string{
"create_time": "ByCreateTimeAsc",
"active_time": "ByActiveTimeDesc",
}[runtime.Str("sort")]
if old, ok := aliasFlagValue(runtime, "sort-type", "sort"); ok {
sortType = old // old value is already the upstream enum -> pass through
}
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"sort_type": runtime.Str("sort-type"),
"sort_type": sortType,
}
if n := runtime.Int("page-size"); n > 0 {
params["page_size"] = n

View File

@@ -611,3 +611,85 @@ func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
}
}
func TestChatList_SortMapping(t *testing.T) {
cases := []struct{ sort, want string }{
{"create_time", "ByCreateTimeAsc"},
{"active_time", "ByActiveTimeDesc"},
}
for _, c := range cases {
t.Run(c.sort, func(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{"sort": c.sort}, nil)
got := buildChatListParams(rt, "")
if got["sort_type"] != c.want {
t.Fatalf("sort=%s -> sort_type=%v, want %s", c.sort, got["sort_type"], c.want)
}
})
}
}
// TestChatList_SortAliasParity proves the hidden --sort-type alias maps to the
// exact same upstream request as the equivalent new --sort value (byte-equal).
func TestChatList_SortAliasParity(t *testing.T) {
pairs := []struct{ newVal, oldVal string }{
{"create_time", "ByCreateTimeAsc"},
{"active_time", "ByActiveTimeDesc"},
}
for _, p := range pairs {
t.Run(p.newVal, func(t *testing.T) {
newRT := newChatListTestRuntimeContext(t, map[string]string{"sort": p.newVal}, nil)
oldRT := newChatListTestRuntimeContext(t, map[string]string{"sort-type": p.oldVal}, nil)
a := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), newRT))
b := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), oldRT))
if a != b {
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
}
})
}
}
// TestChatList_SortNewWins: both flags set -> new wins, old ignored, no error.
func TestChatList_SortNewWins(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"sort": "active_time",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt, "")
if got["sort_type"] != "ByActiveTimeDesc" {
t.Fatalf("new should win: sort_type=%v, want ByActiveTimeDesc", got["sort_type"])
}
}
// TestChatList_SortFlagSurface asserts the declared flag structure.
func TestChatList_SortFlagSurface(t *testing.T) {
var sortFlag, aliasFlag *common.Flag
for i := range ImChatList.Flags {
switch ImChatList.Flags[i].Name {
case "sort":
sortFlag = &ImChatList.Flags[i]
case "sort-type":
aliasFlag = &ImChatList.Flags[i]
}
}
if sortFlag == nil || aliasFlag == nil {
t.Fatalf("expected both --sort and --sort-type flags declared")
}
if sortFlag.Default != "create_time" {
t.Errorf("--sort Default = %q, want create_time", sortFlag.Default)
}
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,active_time" {
t.Errorf("--sort Enum = %q, want create_time,active_time", got)
}
if !strings.Contains(sortFlag.Desc, "create_time") || !strings.Contains(sortFlag.Desc, "active_time") {
t.Errorf("--sort Desc must document both fields/directions: %q", sortFlag.Desc)
}
if !aliasFlag.Hidden {
t.Errorf("--sort-type must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" {
t.Errorf("--sort-type Enum = %q, want ByCreateTimeAsc,ByActiveTimeDesc", got)
}
if aliasFlag.Default != "" {
t.Errorf("--sort-type (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
}
}

View File

@@ -32,7 +32,8 @@ var ImChatMessageList = common.Shortcut{
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
{Name: "start", Desc: "start time (ISO 8601)"},
{Name: "end", Desc: "end time (ISO 8601)"},
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
{Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
@@ -209,7 +210,11 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
}
func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) (larkcore.QueryParams, error) {
params := buildChatMessageListParams(runtime.Str("sort"), runtime.Str("page-size"), chatId)
dir := runtime.Str("order")
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
dir = old // old value is asc/desc -> must go through the same map, never pass through
}
params := buildChatMessageListParams(dir, runtime.Str("page-size"), chatId)
if startFlag := runtime.Str("start"); startFlag != "" {
startTime, err := common.ParseTime(startFlag)

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// newMsgListTestRT registers chat-id (so the builder has a container) plus the
// sort flags under test; only flags present in stringFlags are "set" (Changed).
func newMsgListTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
t.Helper()
if stringFlags == nil {
stringFlags = map[string]string{}
}
if _, ok := stringFlags["chat-id"]; !ok {
stringFlags["chat-id"] = "oc_test"
}
return newChatListTestRuntimeContext(t, stringFlags, nil)
}
func TestChatMessagesList_OrderMapping(t *testing.T) {
cases := []struct{ order, want string }{
{"asc", "ByCreateTimeAsc"},
{"desc", "ByCreateTimeDesc"},
}
for _, c := range cases {
t.Run(c.order, func(t *testing.T) {
rt := newMsgListTestRT(t, map[string]string{"order": c.order})
params, err := buildChatMessageListRequest(rt, "oc_test")
if err != nil {
t.Fatalf("buildChatMessageListRequest() error = %v", err)
}
if got := params["sort_type"][0]; got != c.want {
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, got, c.want)
}
})
}
}
// TestChatMessagesList_OrderAliasParity: hidden --sort alias (asc/desc) must map
// through the SAME table as --order (NOT pass through), producing identical upstream.
func TestChatMessagesList_OrderAliasParity(t *testing.T) {
for _, dir := range []string{"asc", "desc"} {
t.Run(dir, func(t *testing.T) {
newRT := newMsgListTestRT(t, map[string]string{"order": dir})
oldRT := newMsgListTestRT(t, map[string]string{"sort": dir})
a := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), newRT))
b := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), oldRT))
if a != b {
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
}
})
}
}
func TestChatMessagesList_OrderNewWins(t *testing.T) {
rt := newMsgListTestRT(t, map[string]string{"order": "asc", "sort": "desc"})
params, err := buildChatMessageListRequest(rt, "oc_test")
if err != nil {
t.Fatalf("error = %v", err)
}
if got := params["sort_type"][0]; got != "ByCreateTimeAsc" {
t.Fatalf("new should win: sort_type=%s, want ByCreateTimeAsc", got)
}
}
func TestChatMessagesList_OrderFlagSurface(t *testing.T) {
var orderFlag, aliasFlag *common.Flag
for i := range ImChatMessageList.Flags {
switch ImChatMessageList.Flags[i].Name {
case "order":
orderFlag = &ImChatMessageList.Flags[i]
case "sort":
aliasFlag = &ImChatMessageList.Flags[i]
}
}
if orderFlag == nil || aliasFlag == nil {
t.Fatalf("expected both --order and --sort flags declared")
}
if orderFlag.Default != "desc" {
t.Errorf("--order Default = %q, want desc", orderFlag.Default)
}
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
t.Errorf("--order Enum = %q, want asc,desc", got)
}
if !aliasFlag.Hidden {
t.Errorf("--sort must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "asc,desc" {
t.Errorf("--sort (alias) Enum = %q, want asc,desc", got)
}
}

View File

@@ -35,7 +35,8 @@ var ImChatSearch = common.Shortcut{
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
{Name: "sort-by", Desc: "sort field (descending)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
{Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}},
{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
@@ -209,8 +210,8 @@ var ImChatSearch = common.Shortcut{
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
// from the runtime flag values. The query string is normalized via
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
// is omitted when no filter flags are set; "sorter" is omitted when --sort-by
// is empty.
// is omitted when no filter flags are set; "sorter" is omitted when --sort
// (and its hidden alias --sort-by) is unset.
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
@@ -256,9 +257,18 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
body["filter"] = filter
}
// Build sorters (always descending)
if sortBy := runtime.Str("sort-by"); sortBy != "" {
body["sorter"] = sortBy
// Build sorter (always descending). --sort maps field -> field_desc; the hidden
// --sort-by alias is already the upstream value (pass-through). Omitted when unset.
sorter := map[string]string{
"create_time": "create_time_desc",
"update_time": "update_time_desc",
"member_count": "member_count_desc",
}[runtime.Str("sort")]
if old, ok := aliasFlagValue(runtime, "sort-by", "sort"); ok {
sorter = old
}
if sorter != "" {
body["sorter"] = sorter
}
return body

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func newSearchTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
t.Helper()
if stringFlags == nil {
stringFlags = map[string]string{}
}
if _, ok := stringFlags["query"]; !ok {
stringFlags["query"] = "team"
}
return newChatListTestRuntimeContext(t, stringFlags, nil)
}
func TestChatSearch_SortMapping(t *testing.T) {
cases := []struct{ sort, want string }{
{"create_time", "create_time_desc"},
{"update_time", "update_time_desc"},
{"member_count", "member_count_desc"},
}
for _, c := range cases {
t.Run(c.sort, func(t *testing.T) {
rt := newSearchTestRT(t, map[string]string{"sort": c.sort})
body := buildSearchChatBody(rt)
if body["sorter"] != c.want {
t.Fatalf("sort=%s -> sorter=%v, want %s", c.sort, body["sorter"], c.want)
}
})
}
}
// TestChatSearch_SortOmittedWhenUnset: no --sort and no --sort-by -> sorter omitted.
func TestChatSearch_SortOmittedWhenUnset(t *testing.T) {
rt := newSearchTestRT(t, nil)
body := buildSearchChatBody(rt)
if _, present := body["sorter"]; present {
t.Fatalf("sorter should be omitted when neither --sort nor --sort-by set")
}
}
// TestChatSearch_SortAliasParity: hidden --sort-by value is already the upstream
// sorter (pass-through), so it must equal the mapped new --sort body.
func TestChatSearch_SortAliasParity(t *testing.T) {
pairs := []struct{ newVal, oldVal string }{
{"create_time", "create_time_desc"},
{"update_time", "update_time_desc"},
{"member_count", "member_count_desc"},
}
for _, p := range pairs {
t.Run(p.newVal, func(t *testing.T) {
newBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort": p.newVal}))
oldBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort-by": p.oldVal}))
if newBody["sorter"] != oldBody["sorter"] {
t.Fatalf("alias parity: new sorter=%v, old sorter=%v", newBody["sorter"], oldBody["sorter"])
}
})
}
}
func TestChatSearch_SortNewWins(t *testing.T) {
rt := newSearchTestRT(t, map[string]string{"sort": "member_count", "sort-by": "create_time_desc"})
body := buildSearchChatBody(rt)
if body["sorter"] != "member_count_desc" {
t.Fatalf("new should win: sorter=%v, want member_count_desc", body["sorter"])
}
}
func TestChatSearch_SortFlagSurface(t *testing.T) {
var sortFlag, aliasFlag *common.Flag
for i := range ImChatSearch.Flags {
switch ImChatSearch.Flags[i].Name {
case "sort":
sortFlag = &ImChatSearch.Flags[i]
case "sort-by":
aliasFlag = &ImChatSearch.Flags[i]
}
}
if sortFlag == nil || aliasFlag == nil {
t.Fatalf("expected both --sort and --sort-by flags declared")
}
if sortFlag.Default != "" {
t.Errorf("--sort must have no default (sorter omitted when unset), got %q", sortFlag.Default)
}
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,update_time,member_count" {
t.Errorf("--sort Enum = %q", got)
}
if !aliasFlag.Hidden {
t.Errorf("--sort-by must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "create_time_desc,update_time_desc,member_count_desc" {
t.Errorf("--sort-by Enum = %q", got)
}
}

View File

@@ -31,7 +31,8 @@ var ImThreadsMessagesList = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
{Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}},
{Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
{Name: "page-token", Desc: "page token"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
@@ -39,15 +40,10 @@ var ImThreadsMessagesList = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
threadFlag := runtime.Str("thread")
sortFlag := runtime.Str("sort")
dir := resolveThreadsOrder(runtime)
pageSizeStr := runtime.Str("page-size")
pageToken := runtime.Str("page-token")
sortType := "ByCreateTimeAsc"
if sortFlag == "desc" {
sortType = "ByCreateTimeDesc"
}
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
d := common.NewDryRunAPI()
@@ -57,21 +53,12 @@ var ImThreadsMessagesList = common.Shortcut{
containerID = "<resolved_thread_id>"
}
params := map[string]interface{}{
"container_id_type": "thread",
"container_id": containerID,
"sort_type": sortType,
"page_size": pageSize,
"card_msg_content_type": "raw_card_content",
}
if pageToken != "" {
params["page_token"] = pageToken
}
params := buildThreadsMessagesListParams(dir, containerID, pageSize, pageToken)
d = d.
GET("/open-apis/im/v1/messages").
Params(params).
Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr)
Params(toDryParams(params)).
Set("thread", threadFlag).Set("order", dir).Set("page_size", pageSizeStr)
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
@@ -97,26 +84,12 @@ var ImThreadsMessagesList = common.Shortcut{
if err != nil {
return err
}
sortFlag := runtime.Str("sort")
dir := resolveThreadsOrder(runtime)
pageToken := runtime.Str("page-token")
sortType := "ByCreateTimeAsc"
if sortFlag == "desc" {
sortType = "ByCreateTimeDesc"
}
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
params := map[string][]string{
"container_id_type": []string{"thread"},
"container_id": []string{threadId},
"sort_type": []string{sortType},
"page_size": []string{strconv.Itoa(pageSize)},
"card_msg_content_type": []string{"raw_card_content"},
}
if pageToken != "" {
params["page_token"] = []string{pageToken}
}
params := buildThreadsMessagesListParams(dir, threadId, pageSize, pageToken)
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
if err != nil {
@@ -188,3 +161,45 @@ var ImThreadsMessagesList = common.Shortcut{
return nil
},
}
// buildThreadsMessagesListParams builds the upstream query params shared by
// DryRun and Execute, so the asc/desc -> sort_type mapping lives in exactly one
// place (precondition for the dry-run == real alias-parity test).
func buildThreadsMessagesListParams(dir, containerID string, pageSize int, pageToken string) map[string][]string {
sortType := "ByCreateTimeAsc"
if dir == "desc" {
sortType = "ByCreateTimeDesc"
}
params := map[string][]string{
"container_id_type": {"thread"},
"container_id": {containerID},
"sort_type": {sortType},
"page_size": {strconv.Itoa(pageSize)},
"card_msg_content_type": {"raw_card_content"},
}
if pageToken != "" {
params["page_token"] = []string{pageToken}
}
return params
}
// resolveThreadsOrder picks --order, falling back to the hidden --sort alias.
func resolveThreadsOrder(runtime *common.RuntimeContext) string {
dir := runtime.Str("order")
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
dir = old
}
return dir
}
// toDryParams flattens single-valued query params to scalars for dry-run preview,
// matching the historical dry-run JSON shape.
func toDryParams(p map[string][]string) map[string]interface{} {
out := make(map[string]interface{}, len(p))
for k, v := range p {
if len(v) > 0 {
out[k] = v[0]
}
}
return out
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func newThreadsTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
t.Helper()
if stringFlags == nil {
stringFlags = map[string]string{}
}
if _, ok := stringFlags["thread"]; !ok {
stringFlags["thread"] = "omt_test"
}
return newChatListTestRuntimeContext(t, stringFlags, nil)
}
func TestThreadsMessagesList_OrderMapping(t *testing.T) {
cases := []struct{ order, want string }{
{"asc", "ByCreateTimeAsc"},
{"desc", "ByCreateTimeDesc"},
}
for _, c := range cases {
t.Run(c.order, func(t *testing.T) {
got := buildThreadsMessagesListParams(c.order, "omt_test", 50, "")
if v := got["sort_type"][0]; v != c.want {
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, v, c.want)
}
})
}
}
// TestThreadsMessagesList_OrderAliasParity proves DryRun(--sort dir) == DryRun(--order dir).
// This is the test the refactor exists to make meaningful (single shared mapping).
func TestThreadsMessagesList_OrderAliasParity(t *testing.T) {
for _, dir := range []string{"asc", "desc"} {
t.Run(dir, func(t *testing.T) {
newRT := newThreadsTestRT(t, map[string]string{"order": dir})
oldRT := newThreadsTestRT(t, map[string]string{"sort": dir})
a := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), newRT))
b := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), oldRT))
if a != b {
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
}
})
}
}
func TestThreadsMessagesList_OrderFlagSurface(t *testing.T) {
var orderFlag, aliasFlag *common.Flag
for i := range ImThreadsMessagesList.Flags {
switch ImThreadsMessagesList.Flags[i].Name {
case "order":
orderFlag = &ImThreadsMessagesList.Flags[i]
case "sort":
aliasFlag = &ImThreadsMessagesList.Flags[i]
}
}
if orderFlag == nil || aliasFlag == nil {
t.Fatalf("expected both --order and --sort flags declared")
}
if orderFlag.Default != "asc" {
t.Errorf("--order Default = %q, want asc", orderFlag.Default)
}
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
t.Errorf("--order Enum = %q, want asc,desc", got)
}
if !aliasFlag.Hidden {
t.Errorf("--sort must be Hidden")
}
if aliasFlag.Default != "" {
t.Errorf("--sort (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
}
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import "github.com/larksuite/cli/shortcuts/common"
// aliasFlagValue handles a renamed sort flag whose old name is kept as a silent
// alias. It returns (oldValue, true) only when the old flag was explicitly used
// and the new one was not; otherwise ("", false) — meaning "no old flag, or both
// given (new wins), so use the new-flag logic". Pure function, no IO: callable
// from DryRun, Execute, and minimal test fixtures alike. Never prints anything.
func aliasFlagValue(rt *common.RuntimeContext, oldName, newName string) (string, bool) {
if rt.Changed(oldName) && !rt.Changed(newName) {
return rt.Str(oldName), true
}
return "", false
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newAliasTestRT registers a new flag (with a default) and an old flag, then
// sets only the flags present in `set` — so Changed() reflects exactly which
// flags were "passed on the command line".
func newAliasTestRT(t *testing.T, newName, newDefault, oldName string, set map[string]string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String(newName, newDefault, "")
cmd.Flags().String(oldName, "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for k, v := range set {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("Set(%q) error = %v", k, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestAliasFlagValue(t *testing.T) {
cases := []struct {
name string
set map[string]string
wantVal string
wantOK bool
}{
{"only old set", map[string]string{"sort-type": "ByActiveTimeDesc"}, "ByActiveTimeDesc", true},
{"neither set", nil, "", false},
{"only new set", map[string]string{"sort": "active_time"}, "", false},
{"both set new wins", map[string]string{"sort": "active_time", "sort-type": "ByCreateTimeAsc"}, "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newAliasTestRT(t, "sort", "create_time", "sort-type", c.set)
gotVal, gotOK := aliasFlagValue(rt, "sort-type", "sort")
if gotVal != c.wantVal || gotOK != c.wantOK {
t.Fatalf("aliasFlagValue() = (%q, %v), want (%q, %v)", gotVal, gotOK, c.wantVal, c.wantOK)
}
})
}
}

View File

@@ -9,19 +9,19 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// MailMessage is the `+message` shortcut: fetch full content of a single
// email by message ID (normalized body + attachments / inline metadata).
// MailMessage is the `+message` shortcut: fetch full content of one email
// by one message ID (normalized body + attachments / inline metadata).
var MailMessage = common.Shortcut{
Service: "mail",
Command: "+message",
Description: "Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.",
Description: "Use only when reading full content for one email by one message ID. For multiple message IDs, use mail +messages; do not loop mail +message. Returns normalized body content plus attachments metadata, including inline images.",
Risk: "read",
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
{Name: "message-id", Desc: "Required. Email message ID", Required: true},
{Name: "message-id", Desc: "Required. Single email message ID only. For multiple IDs, use mail +messages --message-ids.", Required: true},
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},

View File

@@ -18,19 +18,19 @@ type mailMessagesOutput struct {
}
// MailMessages is the `+messages` shortcut: batch-fetch full content for
// multiple message IDs, chunking backend calls into batches of 20 while
// preserving request order.
// multiple message IDs, chunking requests into batches of 20 while preserving
// request order.
var MailMessages = common.Shortcut{
Service: "mail",
Command: "+messages",
Description: "Use when reading full content for multiple emails by message ID. Prefer this shortcut over calling raw mail user_mailbox.messages batch_get directly, because it base64url-decodes body fields and returns normalized per-message output that is easier to consume.",
Description: "Use when reading full content for multiple emails by message ID. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output while preserving request order.",
Risk: "read",
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. Example: "id1,id2,id3"`, Required: true},
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output. Example: "<id1>,<id2>,<id3>"`, Required: true},
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
@@ -52,7 +52,7 @@ var MailMessages = common.Shortcut{
body["message_ids"] = messageIDs
}
return common.NewDryRunAPI().
Desc("Fetch multiple emails via messages.batch_get (auto-chunked in batches of 20 IDs during execution)").
Desc("Fetch multiple emails; execution chunks every 20 IDs and merges output").
POST(mailboxPath(mailboxID, "messages", "batch_get")).
Body(body)
},

View File

@@ -0,0 +1,220 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestMailMessageHelpClarifiesSingleMessageOnly(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcutWithCobraOutput(t, MailMessage, []string{"+message", "-h"}, f, stdout)
if err != nil {
t.Fatalf("help returned error: %v", err)
}
help := stdout.String()
for _, want := range []string{
"Use only when reading full content for one email by one message ID",
"For multiple message IDs, use mail +messages; do not loop mail +message",
"Single email message ID only",
"mail +messages --message-ids",
} {
if !strings.Contains(help, want) {
t.Fatalf("help missing %q\n%s", want, help)
}
}
}
func TestMailMessagesHelpClarifiesBatchGetChunkingAndLimits(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcutWithCobraOutput(t, MailMessages, []string{"+messages", "-h"}, f, stdout)
if err != nil {
t.Fatalf("help returned error: %v", err)
}
help := stdout.String()
for _, want := range []string{
"multiple emails by message ID",
"handles them in batches of 20 and merges output",
"Comma-separated email message IDs",
"You may pass more than 20 IDs",
} {
if !strings.Contains(help, want) {
t.Fatalf("help missing %q\n%s", want, help)
}
}
for _, disallowed := range []string{"messages.batch_get", "OAPI Meta", "gateway config", "50 IDs", "50 个"} {
if strings.Contains(help, disallowed) {
t.Fatalf("help must not expose internal wording %q\n%s", disallowed, help)
}
}
}
func TestMailMessagesDryRunMentionsBatchGetChunkingAndMerge(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
messageIDs := []string{
validMessageIDForTest("dry-run-1"),
validMessageIDForTest("dry-run-2"),
}
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--message-ids", strings.Join(messageIDs, ","), "--dry-run", "--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run returned error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"chunks every 20 IDs",
"merges output",
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run missing %q\n%s", want, out)
}
}
}
func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
}
reg.Verify(t)
errOut := stderr.String()
for _, want := range []string{
"tip: read full content:",
"single message use mail +message --message-id <id>",
"multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>",
} {
if !strings.Contains(errOut, want) {
t.Fatalf("stderr missing %q\n%s", want, errOut)
}
}
}
func TestMailTriageJSONDoesNotEmitReadHint(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--format", "json", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
}
reg.Verify(t)
if strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("json output must not emit table read hint\nstderr=%s", stderr.String())
}
}
func TestMailMessagesExecuteChunksTwentyOneIDsIntoTwoBatchGetCalls(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
Reusable: true,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"messages": []interface{}{}},
},
}
reg.Register(stub)
ids := make([]string, 21)
for i := range ids {
ids[i] = validMessageIDForTest(fmt.Sprintf("batch-%02d", i+1))
}
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--message-ids", strings.Join(ids, ","),
}, f, stdout)
if err != nil {
t.Fatalf("messages returned error: %v", err)
}
if got := len(stub.CapturedBodies); got != 2 {
t.Fatalf("expected 2 batch_get calls, got %d", got)
}
assertBatchGetMessageIDCount(t, stub.CapturedBodies[0], 20)
assertBatchGetMessageIDCount(t, stub.CapturedBodies[1], 1)
}
func registerTriageReadHintStubs(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/messages",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{"msg_1"},
"has_more": false,
"page_token": "",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"messages": []interface{}{
map[string]interface{}{
"message_id": "msg_1",
"subject": "Quarterly update",
"date": "Thu, 04 Jun 2026 10:00:00 +0800",
"from": map[string]interface{}{"name": "Alice", "mail_address": "alice@example.com"},
},
},
},
},
})
}
func assertBatchGetMessageIDCount(t *testing.T, body []byte, want int) {
t.Helper()
var payload struct {
MessageIDs []string `json:"message_ids"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("unmarshal batch_get body: %v\n%s", err, string(body))
}
if got := len(payload.MessageIDs); got != want {
t.Fatalf("message_ids count mismatch: got %d want %d body=%s", got, want, string(body))
}
}
func runMountedMailShortcutWithCobraOutput(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "test"}
parent.SetOut(stdout)
parent.SetErr(stdout)
shortcut.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
stdout.Reset()
return parent.Execute()
}

View File

@@ -322,9 +322,10 @@ var MailTriage = common.Shortcut{
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
if mailbox != "me" {
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --mailbox "+shellQuote(mailbox)+" --message-id <id> to read full content")
quotedMailbox := shellQuote(mailbox)
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
} else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
}
}
return nil

209
shortcuts/note/note.go Normal file
View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package note owns the Note domain: querying note detail and the unified
// transcript by a known note_id. The vc domain locates a
// note_id from meeting context and delegates note-detail parsing here, so the
// parsing logic lives in exactly one place.
package note
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// NoNoteReadPermissionCode is returned when the caller lacks read permission
// for the requested note.
const NoNoteReadPermissionCode = 121005
// ErrEmptyDetail identifies note detail responses that do not contain a note
// object. Callers should use errors.Is instead of matching the display message.
var ErrEmptyDetail = errors.New("note detail is empty")
// artifact_type enum from the note detail API.
const (
artifactTypeMainDoc = 1 // main note document
artifactTypeVerbatim = 2 // verbatim transcript
)
// note_display_type enum (i32) from the note detail API. Surfaced to callers as
// a stable string so Agents route on a name, not a magic number.
const (
displayTypeNormal = 1
displayTypeUnified = 2
)
// Detail is the parsed note detail shared by `note +detail` and `vc +notes`.
type Detail struct {
NoteID string
CreatorID string
CreateTime string
DisplayType string // unknown | normal | unified
NoteDocToken string
VerbatimDocToken string
SharedDocTokens []string
}
// FetchDetail queries GET /open-apis/vc/v1/notes/{note_id} and parses the note
// object. API errors are returned as typed errs.* values so callers can enrich
// user guidance without downgrading the envelope.
func FetchDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) (*Detail, error) {
data, err := runtime.DoAPIJSONTyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
if err != nil {
return nil, err
}
noteObj, _ := data["note"].(map[string]any)
if noteObj == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "note detail is empty").WithCause(ErrEmptyDetail)
}
noteDoc, verbatimDoc := extractArtifactTokens(common.GetSlice(noteObj, "artifacts"))
return &Detail{
NoteID: noteID,
CreatorID: common.GetString(noteObj, "creator_id"),
CreateTime: common.FormatTime(noteObj["create_time"]),
DisplayType: displayTypeString(displayTypeValue(noteObj)),
NoteDocToken: noteDoc,
VerbatimDocToken: verbatimDoc,
SharedDocTokens: extractDocTokens(common.GetSlice(noteObj, "references")),
}, nil
}
// ToMap renders the detail as the field map consumed by `vc +notes`, keeping
// the historical key set (shared_doc_tokens omitted when empty) and adding the
// note_id / note_display_type fields.
func (d *Detail) ToMap() map[string]any {
m := map[string]any{
"note_id": d.NoteID,
"note_display_type": d.DisplayType,
"creator_id": d.CreatorID,
"create_time": d.CreateTime,
"note_doc_token": d.NoteDocToken,
"verbatim_doc_token": d.VerbatimDocToken,
}
if len(d.SharedDocTokens) > 0 {
m["shared_doc_tokens"] = d.SharedDocTokens
}
return m
}
// displayTypeValue reads the display-type field, tolerating either the
// documented note_display_type key or a bare display_type fallback.
func displayTypeValue(note map[string]any) any {
if v, ok := note["note_display_type"]; ok {
return v
}
return note["display_type"]
}
func displayTypeString(v any) string {
switch parseLooseInt(v) {
case displayTypeNormal:
return "normal"
case displayTypeUnified:
return "unified"
default:
return "unknown"
}
}
// extractArtifactTokens picks main-doc and verbatim-doc tokens from artifacts.
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
for _, a := range artifacts {
artifact, _ := a.(map[string]any)
if artifact == nil {
continue
}
docToken, _ := artifact["doc_token"].(string)
switch parseLooseInt(artifact["artifact_type"]) {
case artifactTypeMainDoc:
noteDoc = docToken
case artifactTypeVerbatim:
verbatimDoc = docToken
}
}
return
}
// extractDocTokens collects doc_token values from a list of reference objects.
func extractDocTokens(refs []any) []string {
var tokens []string
for _, s := range refs {
source, _ := s.(map[string]any)
if source == nil {
continue
}
if docToken, _ := source["doc_token"].(string); docToken != "" {
tokens = append(tokens, docToken)
}
}
return tokens
}
// parseLooseInt extracts an int from the varying JSON number representations
// DoAPIJSON may yield (json.Number, float64, or int).
func parseLooseInt(v any) int {
switch n := v.(type) {
case json.Number:
i, _ := n.Int64()
return int(i)
case float64:
// Reject fractional values: truncating 1.9 to 1 would silently coerce
// a malformed enum into a valid one.
if n != float64(int64(n)) {
return 0
}
return int(n)
case int:
return n
default:
return 0
}
}
// parseLooseCursorID extracts a positive cursor as a string. String cursors are
// preferred because large JSON numbers lose precision when decoded into any.
func parseLooseCursorID(v any) (string, bool) {
switch n := v.(type) {
case string:
s := strings.TrimSpace(n)
if s == "" || s == "0" {
return "", false
}
return s, true
case json.Number:
i, err := n.Int64()
if err != nil || i <= 0 {
return "", false
}
return strconv.FormatInt(i, 10), true
case float64:
// encoding/json decodes numbers in map[string]any as float64. Accept
// only values that can round-trip safely as an integer cursor.
const maxSafeJSONInteger = 1<<53 - 1
if n <= 0 || n != float64(int64(n)) || n > maxSafeJSONInteger {
return "", false
}
return strconv.FormatInt(int64(n), 10), true
case int64:
if n <= 0 {
return "", false
}
return strconv.FormatInt(n, 10), true
case int:
if n <= 0 {
return "", false
}
return strconv.Itoa(n), true
default:
return "", false
}
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// note +detail — get note metadata and document tokens by a known note_id.
package note
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// NoteDetail queries note metadata, display type and document tokens by note_id.
var NoteDetail = common.Shortcut{
Service: "note",
Command: "+detail",
Description: "Get note detail (display type, document tokens) by note_id",
Risk: "read",
Scopes: []string{"vc:note:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "note-id", Desc: "note ID", Required: true},
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
noteID := strings.TrimSpace(runtime.Str("note-id"))
if noteID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")
}
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)
}
return nil
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
noteID := strings.TrimSpace(runtime.Str("note-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
noteID := strings.TrimSpace(runtime.Str("note-id"))
detail, err := FetchDetail(ctx, runtime, noteID)
if err != nil {
return mapNoteError(err)
}
runtime.OutFormat(map[string]any{"note": detail.ToMap()}, nil, nil)
return nil
},
}
// mapNoteError surfaces the no-permission case explicitly and passes through
// any other typed API error unchanged.
func mapNoteError(err error) error {
if problem, ok := errs.ProblemOf(err); ok && problem.Code == NoNoteReadPermissionCode {
message := strings.TrimSpace(problem.Message)
if message == "" {
message = "no read permission for this note"
} else if !strings.Contains(message, "no read permission for this note") {
message = fmt.Sprintf("no read permission for this note: %s", message)
}
var permErr *errs.PermissionError
if errors.As(err, &permErr) {
mapped := *permErr
mapped.Problem.Message = message
if mapped.Problem.Hint == "" {
mapped.Problem.Hint = "Ask the note owner to grant read permission, then retry"
}
mapped.Cause = err
return &mapped
}
mappedProblem := *problem
mappedProblem.Category = errs.CategoryAuthorization
mappedProblem.Subtype = errs.SubtypePermissionDenied
mappedProblem.Message = message
if mappedProblem.Hint == "" {
mappedProblem.Hint = "Ask the note owner to grant read permission, then retry"
}
return &errs.PermissionError{Problem: mappedProblem, Cause: err}
}
return err
}

280
shortcuts/note/note_test.go Normal file
View File

@@ -0,0 +1,280 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package note
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// These tests were relocated from shortcuts/vc/vc_notes_test.go together with
// the note-detail parsing helpers they cover.
func TestParseLooseInt(t *testing.T) {
tests := []struct {
input any
want int
}{
{float64(1), 1},
{float64(2), 2},
{float64(1.9), 0},
{json.Number("3"), 3},
{"unknown", 0},
{nil, 0},
}
for _, tt := range tests {
got := parseLooseInt(tt.input)
if got != tt.want {
t.Errorf("parseLooseInt(%v) = %d, want %d", tt.input, got, tt.want)
}
}
}
func TestParseLooseCursorID(t *testing.T) {
tests := []struct {
name string
in any
want string
ok bool
}{
{name: "string", in: "7648924766078847940", want: "7648924766078847940", ok: true},
{name: "trim string", in: " 123 ", want: "123", ok: true},
{name: "empty string", in: "", ok: false},
{name: "zero string", in: "0", ok: false},
{name: "json number", in: json.Number("123"), want: "123", ok: true},
{name: "float safe integer", in: float64(123), want: "123", ok: true},
{name: "float unsafe integer", in: float64(1<<53 + 1), ok: false},
{name: "float fractional", in: float64(1.5), ok: false},
{name: "negative", in: -1, ok: false},
{name: "nil", in: nil, ok: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := parseLooseCursorID(tt.in)
if got != tt.want || ok != tt.ok {
t.Fatalf("parseLooseCursorID(%v) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok)
}
})
}
}
func TestExtractArtifactTokens(t *testing.T) {
artifacts := []any{
map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)},
map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)},
map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)},
nil,
}
noteDoc, verbatimDoc := extractArtifactTokens(artifacts)
if noteDoc != "main_doc" {
t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc")
}
if verbatimDoc != "verbatim_doc" {
t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc")
}
}
func TestExtractArtifactTokens_Empty(t *testing.T) {
noteDoc, verbatimDoc := extractArtifactTokens(nil)
if noteDoc != "" || verbatimDoc != "" {
t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc)
}
}
func TestExtractDocTokens(t *testing.T) {
refs := []any{
map[string]any{"doc_token": "shared1"},
map[string]any{"doc_token": "shared2"},
map[string]any{"doc_token": ""},
map[string]any{},
nil,
}
tokens := extractDocTokens(refs)
if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" {
t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens)
}
}
func TestExtractDocTokens_Empty(t *testing.T) {
tokens := extractDocTokens(nil)
if tokens != nil {
t.Errorf("expected nil for nil input, got %v", tokens)
}
}
func TestDetailToMap(t *testing.T) {
detail := &Detail{
NoteID: "note_1",
CreatorID: "creator_1",
CreateTime: "2026-06-09 12:00:00",
DisplayType: "unified",
NoteDocToken: "note_doc",
VerbatimDocToken: "verbatim_doc",
SharedDocTokens: []string{"shared_1", "shared_2"},
}
got := detail.ToMap()
want := map[string]any{
"note_id": "note_1",
"creator_id": "creator_1",
"create_time": "2026-06-09 12:00:00",
"note_display_type": "unified",
"note_doc_token": "note_doc",
"verbatim_doc_token": "verbatim_doc",
"shared_doc_tokens": []string{"shared_1", "shared_2"},
}
for key, wantValue := range want {
gotValue, ok := got[key]
if !ok {
t.Fatalf("ToMap missing key %q in %#v", key, got)
}
if !valuesEqual(gotValue, wantValue) {
t.Fatalf("ToMap[%q] = %#v, want %#v", key, gotValue, wantValue)
}
}
}
func TestDetailToMap_OmitsEmptySharedDocTokens(t *testing.T) {
got := (&Detail{NoteID: "note_1"}).ToMap()
if _, ok := got["shared_doc_tokens"]; ok {
t.Fatalf("ToMap should omit empty shared_doc_tokens, got %#v", got)
}
}
func TestMapNoteError_NoReadPermission(t *testing.T) {
err := &errs.PermissionError{
Problem: errs.Problem{
Category: errs.CategoryAuthorization,
Subtype: errs.SubtypePermissionDenied,
Code: NoNoteReadPermissionCode,
Message: "upstream permission denied",
LogID: "log_1",
},
MissingScopes: []string{"vc:note:read"},
Identity: "user",
}
got := mapNoteError(err)
problem, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("mapNoteError returned %T, want typed problem", got)
}
if problem.Code != NoNoteReadPermissionCode {
t.Fatalf("mapped code = %d, want %d", problem.Code, NoNoteReadPermissionCode)
}
if !strings.Contains(problem.Message, "no read permission for this note") || !strings.Contains(problem.Message, "upstream permission denied") {
t.Fatalf("mapped message = %q, want note permission guidance with upstream message", problem.Message)
}
if !errors.Is(got, err) {
t.Fatal("mapped error should preserve the original typed error as cause")
}
originalProblem, _ := errs.ProblemOf(err)
if originalProblem.Message != "upstream permission denied" {
t.Fatalf("original message was mutated to %q", originalProblem.Message)
}
var gotPerm *errs.PermissionError
if !errors.As(got, &gotPerm) {
t.Fatalf("mapped error = %T, want PermissionError", got)
}
if gotPerm.LogID != "log_1" {
t.Fatalf("LogID = %q, want preserved log_1", gotPerm.LogID)
}
if len(gotPerm.MissingScopes) != 1 || gotPerm.MissingScopes[0] != "vc:note:read" {
t.Fatalf("MissingScopes = %#v, want preserved vc:note:read", gotPerm.MissingScopes)
}
if gotPerm.Identity != "user" {
t.Fatalf("Identity = %q, want preserved user", gotPerm.Identity)
}
}
func TestMapNoteError_NormalizesNonPermissionTypedError(t *testing.T) {
err := &errs.APIError{
Problem: errs.Problem{
Category: errs.CategoryAPI,
Subtype: errs.SubtypeUnknown,
Code: NoNoteReadPermissionCode,
Message: "upstream api error",
LogID: "log_2",
},
}
got := mapNoteError(err)
var gotPerm *errs.PermissionError
if !errors.As(got, &gotPerm) {
t.Fatalf("mapped error = %T, want PermissionError", got)
}
if gotPerm.Category != errs.CategoryAuthorization || gotPerm.Subtype != errs.SubtypePermissionDenied {
t.Fatalf("mapped category/subtype = %q/%q, want authorization/permission_denied", gotPerm.Category, gotPerm.Subtype)
}
if !strings.Contains(gotPerm.Message, "no read permission for this note") || !strings.Contains(gotPerm.Message, "upstream api error") {
t.Fatalf("mapped message = %q, want note permission guidance with upstream message", gotPerm.Message)
}
if gotPerm.Hint == "" {
t.Fatal("mapped hint should not be empty")
}
if gotPerm.LogID != "log_2" {
t.Fatalf("LogID = %q, want preserved log_2", gotPerm.LogID)
}
if !errors.Is(got, err) {
t.Fatal("mapped error should preserve the original typed error as cause")
}
}
func TestMapNoteError_Passthrough(t *testing.T) {
err := errors.New("boom")
if got := mapNoteError(err); got != err {
t.Fatalf("mapNoteError passthrough = %v, want original", got)
}
}
func TestNoteDetailEmptyDetailPreservesSentinelCause(t *testing.T) {
factory, stdout, _, reg := noteShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/notes/note_empty_detail",
Body: map[string]any{
"code": 0,
"data": map[string]any{},
},
})
err := runNoteShortcut(t, NoteDetail, []string{"+detail", "--note-id", "note_empty_detail", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("expected empty detail to fail")
}
if !errors.Is(err, ErrEmptyDetail) {
t.Fatalf("errors.Is(ErrEmptyDetail) = false for %T: %v", err, err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
}
func TestShortcuts(t *testing.T) {
shortcuts := Shortcuts()
if len(shortcuts) != 2 {
t.Fatalf("Shortcuts len = %d, want 2", len(shortcuts))
}
if shortcuts[0].Command != "+detail" || shortcuts[1].Command != "+transcript" {
t.Fatalf("Shortcuts commands = %q, %q", shortcuts[0].Command, shortcuts[1].Command)
}
}
func valuesEqual(a, b any) bool {
ab, _ := json.Marshal(a)
bb, _ := json.Marshal(b)
return string(ab) == string(bb)
}

View File

@@ -0,0 +1,258 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// note +transcript — fetch the unified note transcript by a
// known note_id. The API is paginated; the CLI walks all pages internally,
// concatenates the content and saves the whole transcript to a local file.
package note
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
transcriptFormatMarkdown = "markdown"
transcriptFormatPlainText = "plain_text"
logPrefix = "[note +transcript]"
// maxTranscriptPages bounds the pagination loop so a misbehaving has_more
// can never spin forever. transcriptPageSize reduces round trips; full
// transcript correctness still depends on has_more/cursor pagination.
maxTranscriptPages = 500
transcriptPageSize = 200
// pageDelay throttles successive page requests to stay gentle on the
// downstream, matching the batch cadence used by `vc +notes`.
pageDelay = 100 * time.Millisecond
// noteArtifactSubdir is the default top-level directory for note-scoped
// artifacts (parallel to the "minutes" layout used by minute artifacts).
noteArtifactSubdir = "notes"
)
// NoteTranscript fetches the full unified transcript and saves it to a file.
var NoteTranscript = common.Shortcut{
Service: "note",
Command: "+transcript",
Description: "Fetch the unified note transcript and save it to a file",
Risk: "read",
Scopes: []string{"vc:note:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "note-id", Desc: "note ID", Required: true},
{Name: "transcript-format", Desc: "transcript content format", Default: transcriptFormatMarkdown, Enum: []string{transcriptFormatMarkdown, transcriptFormatPlainText}},
{Name: "locale", Desc: "transcript locale, e.g. zh_cn, en_us, ja_jp (default follows profile language or brand)"},
{Name: "output", Desc: "output file path (default: ./notes/{note_id}/unified_transcript.{md,txt})"},
{Name: "overwrite", Type: "bool", Desc: "overwrite an existing output file"},
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
noteID := strings.TrimSpace(runtime.Str("note-id"))
if noteID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")
}
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)
}
if out := strings.TrimSpace(runtime.Str("output")); out != "" {
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
return err
}
}
return nil
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
noteID := strings.TrimSpace(runtime.Str("note-id"))
transcriptFormat := runtime.Str("transcript-format")
locale := resolveTranscriptLocale(runtime)
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID))).
Desc("[1] Check note_display_type and verbatim_doc_token before transcript fetch").
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))).
Desc("[2] Fetch unified note transcript pages; subsequent pages add cursor_id internally").
Params(map[string]interface{}{
"format": transcriptFormat,
"page_size": transcriptPageSize,
"locale": locale,
}).
Set("transcript_format", transcriptFormat).
Set("locale", locale).
Set("note", "CLI first checks note_display_type via note detail, then paginates internally (cursor_id) and saves the full unified transcript to a file")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
noteID := strings.TrimSpace(runtime.Str("note-id"))
transcriptFormat := runtime.Str("transcript-format")
locale := resolveTranscriptLocale(runtime)
outPath := strings.TrimSpace(runtime.Str("output"))
if outPath == "" {
outPath = defaultTranscriptPath(noteID, transcriptFormat)
}
if !runtime.Bool("overwrite") {
if _, statErr := runtime.FileIO().Stat(outPath); statErr == nil {
precondition := errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s", outPath).
WithHint("Pass --overwrite to replace the existing file")
if strings.TrimSpace(runtime.Str("output")) != "" {
precondition = precondition.WithParam("--output")
}
return precondition
}
}
if err := ensureUnifiedNote(ctx, runtime, noteID); err != nil {
return err
}
content, err := fetchUnifiedTranscript(ctx, runtime, noteID, transcriptFormat, locale)
if err != nil {
return err
}
saved, err := runtime.FileIO().Save(outPath, fileio.SaveOptions{}, bytes.NewReader(content))
if err != nil {
return common.WrapSaveErrorTyped(err)
}
resolved, rerr := runtime.FileIO().ResolvePath(outPath)
if rerr != nil || resolved == "" {
resolved = outPath
}
runtime.OutFormat(map[string]any{
"note_id": noteID,
"transcript_format": transcriptFormat,
"transcript_file": resolved,
"size_bytes": saved.Size(),
}, nil, nil)
return nil
},
}
func ensureUnifiedNote(ctx context.Context, runtime *common.RuntimeContext, noteID string) error {
detail, err := FetchDetail(ctx, runtime, noteID)
if err != nil {
return mapNoteError(err)
}
if detail.DisplayType != "unified" {
if detail.VerbatimDocToken != "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=%s)", noteID, detail.DisplayType, detail.VerbatimDocToken).
WithHint("Use docs +fetch --api-version v2 --doc %s for normal note transcripts", detail.VerbatimDocToken)
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=)", noteID, detail.DisplayType).
WithHint("Use note +detail to inspect document tokens")
}
return nil
}
// fetchUnifiedTranscript walks every page of the unified transcript and returns
// the concatenated content. Any page error fails the whole call: a partial
// transcript is misleading, so we prefer an explicit error over silent loss.
func fetchUnifiedTranscript(ctx context.Context, runtime *common.RuntimeContext, noteID, transcriptFormat, locale string) ([]byte, error) {
errOut := runtime.IO().ErrOut
apiPath := fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))
var buf bytes.Buffer
var cursor string
seenCursors := map[string]bool{}
for page := 1; ; page++ {
if err := ctx.Err(); err != nil {
return nil, transcriptContextError(err)
}
if page > maxTranscriptPages {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript exceeded %d pages; aborting to avoid an unbounded loop", maxTranscriptPages)
}
query := larkcore.QueryParams{
"format": []string{transcriptFormat},
"locale": []string{locale},
"page_size": []string{strconv.Itoa(transcriptPageSize)},
}
if cursor != "" {
query["cursor_id"] = []string{cursor}
}
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiPath, query, nil)
if err != nil {
return nil, mapNoteError(err)
}
if transcript, _ := data["transcript"].(map[string]any); transcript != nil {
if chunk, _ := transcript[transcriptFormat].(string); chunk != "" {
buf.WriteString(chunk)
}
}
hasMore, _ := data["has_more"].(bool)
if !hasMore {
break
}
next, ok := parseLooseCursorID(data["next_cursor_id"])
if !ok || next == cursor || seenCursors[next] {
fmt.Fprintf(errOut, "%s has_more set but cursor did not advance at page %d\n", logPrefix, page)
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript pagination cursor did not advance at page %d; aborting to avoid saving a partial transcript", page)
}
seenCursors[cursor] = true
cursor = next
timer := time.NewTimer(pageDelay)
select {
case <-ctx.Done():
timer.Stop()
return nil, transcriptContextError(ctx.Err())
case <-timer.C:
}
}
if buf.Len() == 0 {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript is empty for note %s in %s format; aborting to avoid saving an empty transcript", noteID, transcriptFormat)
}
return buf.Bytes(), nil
}
func transcriptContextError(err error) error {
if err == nil {
return nil
}
subtype := errs.SubtypeNetworkTransport
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
}
return errs.NewNetworkError(subtype, "transcript fetch interrupted: %s", err).WithCause(err)
}
// defaultTranscriptPath builds the default save path for a note transcript.
func defaultTranscriptPath(noteID, transcriptFormat string) string {
name := "unified_transcript.md"
if transcriptFormat == transcriptFormatPlainText {
name = "unified_transcript.txt"
}
return filepath.Join(noteArtifactSubdir, noteID, name)
}
func resolveTranscriptLocale(runtime *common.RuntimeContext) string {
if explicit := strings.TrimSpace(runtime.Str("locale")); explicit != "" {
return explicit
}
if lang := runtime.Lang(); lang != "" {
return string(lang)
}
if runtime.Config.Brand == core.BrandLark {
return string(i18n.LangEnUS)
}
return string(i18n.LangZhCN)
}

View File

@@ -0,0 +1,438 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package note
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestNoteTranscriptRequiresUnifiedNote(t *testing.T) {
factory, stdout, _, reg := noteShortcutTestFactory(t)
reg.Register(noteDetailStub("note_normal", displayTypeNormal))
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_normal", "--output", "out.md", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("expected non-unified note to fail")
}
if got := err.Error(); !strings.Contains(got, "not a unified note") || !strings.Contains(got, "note_display_type=normal") || !strings.Contains(got, "verbatim_doc_token=doc_verbatim") {
t.Fatalf("err = %q, want non-unified message", got)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %v, want FailedPrecondition", problem.Subtype)
}
if !strings.Contains(problem.Hint, "docs +fetch --api-version v2 --doc doc_verbatim") {
t.Fatalf("hint = %q, want docs +fetch guidance", problem.Hint)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
}
func TestNoteTranscriptFetchesUnifiedNote(t *testing.T) {
factory, stdout, _, reg := noteShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
reg.Register(noteDetailStub("note_unified", displayTypeUnified))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/notes/note_unified/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"transcript": map[string]interface{}{
"markdown": "# transcript\n",
},
},
},
})
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_unified", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_unified", "unified_transcript.md"))
if err != nil {
t.Fatalf("ReadFile transcript err=%v", err)
}
if string(content) != "# transcript\n" {
t.Fatalf("transcript = %q, want %q", string(content), "# transcript\n")
}
data := decodeNoteEnvelope(t, stdout)
if data["note_id"] != "note_unified" || data["size_bytes"] != float64(len(content)) {
t.Fatalf("unexpected output: %#v", data)
}
}
func TestNoteTranscriptFormatFlagDoesNotShadowOutputFormat(t *testing.T) {
factory, stdout, _, reg := noteShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
reg.Register(noteDetailStub("note_plain", displayTypeUnified))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/notes/note_plain/unified_note_transcript?format=plain_text&locale=zh_cn&page_size=200",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"transcript": map[string]interface{}{
"plain_text": "plain transcript\n",
},
},
},
})
err := runNoteShortcut(t, NoteTranscript, []string{
"+transcript",
"--note-id", "note_plain",
"--transcript-format", "plain_text",
"--format", "json",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_plain", "unified_transcript.txt"))
if err != nil {
t.Fatalf("ReadFile transcript err=%v", err)
}
if string(content) != "plain transcript\n" {
t.Fatalf("transcript = %q, want plain transcript", string(content))
}
data := decodeNoteEnvelope(t, stdout)
if data["transcript_format"] != "plain_text" {
t.Fatalf("transcript_format = %#v, want plain_text; output=%s", data["transcript_format"], stdout.String())
}
if _, ok := data["format"]; ok {
t.Fatalf("output should not expose ambiguous format field: %#v", data)
}
}
func TestNoteTranscriptPassesLocaleThrough(t *testing.T) {
factory, stdout, _, reg := noteShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
reg.Register(noteDetailStub("note_locale", displayTypeUnified))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/notes/note_locale/unified_note_transcript?format=markdown&locale=en_us&page_size=200",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"transcript": map[string]interface{}{
"markdown": "# en transcript\n",
},
},
},
})
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_locale", "--locale", "en_us", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_locale", "unified_transcript.md"))
if err != nil {
t.Fatalf("ReadFile transcript err=%v", err)
}
if string(content) != "# en transcript\n" {
t.Fatalf("transcript = %q, want en transcript", string(content))
}
}
func TestNoteTranscriptDefaultsLocaleFromLarkBrand(t *testing.T) {
config := &core.CliConfig{
AppID: "test-app-lark-locale",
AppSecret: "test-secret",
Brand: core.BrandLark,
UserOpenId: "ou_testuser",
}
factory, stdout, _, reg := noteShortcutTestFactoryWithConfig(t, config)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
reg.Register(noteDetailStub("note_lark", displayTypeUnified))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/notes/note_lark/unified_note_transcript?format=markdown&locale=en_us&page_size=200",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"transcript": map[string]interface{}{
"markdown": "# en transcript\n",
},
},
},
})
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_lark", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
}
func TestNoteTranscriptRejectsExistingOutputBeforeFetch(t *testing.T) {
factory, stdout, _, _ := noteShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
outPath := filepath.Join("notes", "note_exists", "unified_transcript.md")
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
t.Fatalf("MkdirAll err=%v", err)
}
if err := os.WriteFile(outPath, []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile err=%v", err)
}
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_exists", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("expected existing output to fail")
}
if got := err.Error(); !strings.Contains(got, "output file already exists") {
t.Fatalf("err = %q, want existing output error", got)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("err = %T, want ValidationError", err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %v, want FailedPrecondition", validationErr.Subtype)
}
if !strings.Contains(validationErr.Hint, "--overwrite") {
t.Fatalf("hint = %q, want --overwrite guidance", validationErr.Hint)
}
// The CLI picked the default path itself, so no input param is at fault.
if validationErr.Param != "" {
t.Fatalf("param = %q, want empty for default output path", validationErr.Param)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
}
func TestNoteTranscriptRejectsEmptyTranscript(t *testing.T) {
factory, stdout, _, reg := noteShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
reg.Register(noteDetailStub("note_empty", displayTypeUnified))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/notes/note_empty/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"transcript": map[string]interface{}{
"markdown": "",
},
},
},
})
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_empty", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("expected empty transcript to fail")
}
if got := err.Error(); !strings.Contains(got, "transcript is empty") || !strings.Contains(got, "note_empty") {
t.Fatalf("err = %q, want empty transcript error", got)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
}
if _, statErr := os.Stat(filepath.Join(dir, "notes", "note_empty", "unified_transcript.md")); !os.IsNotExist(statErr) {
t.Fatalf("transcript file should not exist, statErr=%v", statErr)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
}
func TestNoteTranscriptRejectsCursorCycle(t *testing.T) {
factory, stdout, _, reg := noteShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
reg.Register(noteDetailStub("note_cycle", displayTypeUnified))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/notes/note_cycle/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": true,
"next_cursor_id": "A",
"transcript": map[string]interface{}{
"markdown": "page1\n",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "cursor_id=A",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": true,
"next_cursor_id": "B",
"transcript": map[string]interface{}{
"markdown": "page2\n",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "cursor_id=B",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": true,
"next_cursor_id": "A",
"transcript": map[string]interface{}{
"markdown": "page3\n",
},
},
},
})
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_cycle", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("expected cursor cycle to fail")
}
if got := err.Error(); !strings.Contains(got, "pagination cursor did not advance") {
t.Fatalf("err = %q, want cursor advance error", got)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
}
if _, statErr := os.Stat(filepath.Join(dir, "notes", "note_cycle", "unified_transcript.md")); !os.IsNotExist(statErr) {
t.Fatalf("transcript file should not exist, statErr=%v", statErr)
}
}
func TestTranscriptContextErrorPreservesCause(t *testing.T) {
tests := []struct {
name string
err error
subtype errs.Subtype
}{
{
name: "canceled",
err: context.Canceled,
subtype: errs.SubtypeNetworkTransport,
},
{
name: "deadline",
err: context.DeadlineExceeded,
subtype: errs.SubtypeNetworkTimeout,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := transcriptContextError(tt.err)
if !errors.Is(err, tt.err) {
t.Fatalf("errors.Is(%v) = false", tt.err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryNetwork || problem.Subtype != tt.subtype {
t.Fatalf("category/subtype = %v/%v, want Network/%v", problem.Category, problem.Subtype, tt.subtype)
}
})
}
}
func noteShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
config := &core.CliConfig{
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_testuser",
}
return noteShortcutTestFactoryWithConfig(t, config)
}
func noteShortcutTestFactoryWithConfig(t *testing.T, config *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
return cmdutil.TestFactory(t, config)
}
func runNoteShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "note"}
shortcut.Mount(parent, factory)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
stdout.Reset()
if stderr, ok := factory.IOStreams.ErrOut.(*bytes.Buffer); ok {
stderr.Reset()
}
return parent.ExecuteContext(context.Background())
}
func noteDetailStub(noteID string, displayType int) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/notes/" + noteID,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"note": map[string]interface{}{
"note_display_type": displayType,
"artifacts": []interface{}{
map[string]interface{}{"artifact_type": artifactTypeVerbatim, "doc_token": "doc_verbatim"},
},
},
},
},
}
}
func decodeNoteEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\nstdout=%s", err, stdout.String())
}
if data, _ := envelope["data"].(map[string]interface{}); data != nil {
return data
}
return envelope
}

View File

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

View File

@@ -29,6 +29,7 @@ import (
"github.com/larksuite/cli/shortcuts/mail"
"github.com/larksuite/cli/shortcuts/markdown"
"github.com/larksuite/cli/shortcuts/minutes"
"github.com/larksuite/cli/shortcuts/note"
"github.com/larksuite/cli/shortcuts/sheets"
sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward"
"github.com/larksuite/cli/shortcuts/slides"
@@ -79,6 +80,7 @@ func init() {
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
allShortcuts = append(allShortcuts, note.Shortcuts()...)
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
allShortcuts = append(allShortcuts, okr.Shortcuts()...)

View File

@@ -13,7 +13,6 @@ package vc
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -30,6 +29,7 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/note"
)
// per-flag additional scope requirements for +notes (vc:note:read is checked by framework)
@@ -51,12 +51,6 @@ var (
}
)
// artifact type enum from note detail API
const (
artifactTypeMainDoc = 1 // main note document
artifactTypeVerbatim = 2 // verbatim transcript
)
const logPrefix = "[vc +notes]"
const (
@@ -66,9 +60,6 @@ const (
recordingNotFoundCode = 121004 // 该会议没有妙记文件
recordingNoPermissionCode = 121005 // 非会议参与者无权查看
recordingGeneratingCode = 124002 // 录制/妙记文件仍在生成中
// note detail API specific error code.
noteNoPermissionCode = 121005 // 调用者没有该纪要的阅读权限
)
func minutesReadError(err error, minuteToken string) error {
@@ -221,7 +212,7 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont
// success means note detail was retrieved, regardless of whether the
// recording API (minute_token) call succeeded — minute_token failures
// surface as part of the merged `error` string for downstream visibility.
if _, ok := noteResult["note_doc_token"].(string); ok {
if noteID, _ := noteResult["note_id"].(string); noteID != "" {
for k, v := range noteResult {
result[k] = v
}
@@ -369,11 +360,13 @@ func joinErrors(msgs ...string) string {
// hasNotesPayload reports whether a result map carries any usable note or
// minute payload, irrespective of partial failures surfaced via `error`.
// note_id counts: it is the routing key for `note +detail` / `note +transcript`,
// so a detail hit without doc tokens is still an actionable result.
func hasNotesPayload(m map[string]any) bool {
if m == nil {
return false
}
for _, k := range []string{"note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
for _, k := range []string{"note_id", "note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
if v, ok := m[k]; ok && v != nil && v != "" {
return true
}
@@ -519,84 +512,22 @@ func saveTranscriptToFile(runtime *common.RuntimeContext, minuteToken, title str
return transcriptPath
}
// parseArtifactType extracts artifact_type as int from varying JSON number representations.
func parseArtifactType(v any) int {
switch n := v.(type) {
case json.Number:
i, _ := n.Int64()
return int(i)
case float64:
return int(n)
default:
return 0
}
}
// extractArtifactTokens picks main-doc and verbatim-doc tokens from the artifacts list.
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
for _, a := range artifacts {
artifact, _ := a.(map[string]any)
if artifact == nil {
continue
}
docToken, _ := artifact["doc_token"].(string)
switch parseArtifactType(artifact["artifact_type"]) {
case artifactTypeMainDoc:
noteDoc = docToken
case artifactTypeVerbatim:
verbatimDoc = docToken
default:
// ignore unknown artifact types
}
}
return
}
// extractDocTokens collects doc_token values from a list of reference objects.
func extractDocTokens(refs []any) []string {
var tokens []string
for _, s := range refs {
source, _ := s.(map[string]any)
if source == nil {
continue
}
if docToken, _ := source["doc_token"].(string); docToken != "" {
tokens = append(tokens, docToken)
}
}
return tokens
}
// fetchNoteDetail retrieves note document tokens via note_id.
func fetchNoteDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) map[string]any {
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
// fetchNoteDetail retrieves note fields via note_id by delegating to the note
// domain (the canonical owner of note-detail parsing) and adapting the typed
// result into the historical map shape `vc +notes` merges into its output. The
// new note_id / note_display_type fields ride along via Detail.ToMap.
func fetchNoteDetail(ctx context.Context, runtime *common.RuntimeContext, noteID string) map[string]any {
detail, err := note.FetchDetail(ctx, runtime, noteID)
if err != nil {
if p, ok := errs.ProblemOf(err); ok && p.Code == noteNoPermissionCode {
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", p.Code)}
if problem, ok := errs.ProblemOf(err); ok && problem.Code == note.NoNoteReadPermissionCode {
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", problem.Code)}
}
if errors.Is(err, note.ErrEmptyDetail) {
return map[string]any{"error": note.ErrEmptyDetail.Error()}
}
return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)}
}
note, _ := data["note"].(map[string]any)
if note == nil {
return map[string]any{"error": "note detail is empty"}
}
creatorID, _ := note["creator_id"].(string)
createTime := common.FormatTime(note["create_time"])
noteDocToken, verbatimDocToken := extractArtifactTokens(common.GetSlice(note, "artifacts"))
sharedDocTokens := extractDocTokens(common.GetSlice(note, "references"))
result := map[string]any{
"creator_id": creatorID,
"create_time": createTime,
"note_doc_token": noteDocToken,
"verbatim_doc_token": verbatimDocToken,
}
if len(sharedDocTokens) > 0 {
result["shared_doc_tokens"] = sharedDocTokens
}
return result
return detail.ToMap()
}
// VCNotes queries meeting notes via meeting-ids, minute-tokens, or calendar-event-ids.
@@ -775,6 +706,12 @@ var VCNotes = common.Shortcut{
id, _ = m["calendar_event_id"].(string)
}
row := map[string]interface{}{"id": id}
if v, _ := m["note_id"].(string); v != "" {
row["note_id"] = v
}
if v, _ := m["note_display_type"].(string); v != "" {
row["note_display_type"] = v
}
if errMsg, _ := m["error"].(string); errMsg != "" {
row["status"] = "FAIL"
row["error"] = errMsg

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