Compare commits

..

23 Commits

Author SHA1 Message Date
fangshuyu
0efde2f901 perf: lazy-load sheets shortcuts by invocation 2026-06-03 18:00:32 +08:00
zhengzhijie
5ac35fd9fd revert(cli): drop non-interactive proxy-warning silencing
WarnIfProxied's interactivity gate is a generic CLI/agent-UX change
unrelated to the sheets refactor / backward-compat scope of this branch.
Split out to a dedicated PR; restore WarnIfProxied to its single-arg form
here (warn.go, warn_test.go, factory_default.go callers).
2026-06-03 14:58:17 +08:00
xiongyuanwen-byted
1decb4399d test(sheets): drop redundant copyloopvar copy in required-flag parity test
Go 1.22+ scopes the loop var per iteration, so `cmd, business := cmd, business`
in TestBatchOp_RequiredFlagParity is a no-op that trips the repo's copyloopvar
linter (same cleanup as 2132472). Behavior unchanged; 45 sub-tests still pass.
2026-06-03 14:47:15 +08:00
zhengzhijie
d05fbcf041 refactor(sheets): drop unused int64 flag-type plumbing
No sheets flag-def declares an int64 type and RuntimeContext.Int64 had
zero callers, so remove the premature support: the RuntimeContext.Int64
helper, the registerShortcutFlagsWithContext int64 branch, the flagView
Int64 method + mapFlagView impl, and the typedDefault/validateRawTypes
int64 cases. float64 (consumed by --font-size) is kept.
2026-06-03 14:44:07 +08:00
xiongyuanwen-byted
cf47d9b5f9 test(sheets): data-driven required-flag parity contract for batch sub-ops
Adds TestBatchOp_RequiredFlagParity, the systematic standalone-vs-batch parity
check the branch review asked for. Data-driven over batchOpDispatch + flag-defs,
it asserts that for every batchable shortcut a +batch-update sub-op which
satisfies the sheet locator but omits the shortcut's business-required flags
fails in translateBatchOp, never silently defaulting.

This generalizes the hand-picked TestBatchOp_ErrorEquivalence / GuardsBeyondCobra
cases to the full 50-command surface and auto-covers shortcuts added later, so a
future refactor that moves a required check out of the shared *Input builder
(the failure mode behind the csv-put / sheet-move gaps) is caught here. 45
sub-tests run; locator-only commands (+sheet-delete / +sheet-hide / ...) have no
business-required flag to omit and are skipped. A missing-locator error is also
rejected so a bad fixture can't mask a real gap.
2026-06-03 14:40:19 +08:00
zhengzhijie
2132472b87 fix(sheets): silence nilerr/copyloopvar lint in batch type-check additions
- flag_view.go: annotate the fail-open return in validateRawTypes with
  //nolint:nilerr (matches the repo convention for intentional fail-open).
- execute_paths_test.go: drop the redundant tc := tc copy (Go 1.22+ scopes
  the loop var per iteration).
2026-06-03 14:33:19 +08:00
zhengzhijie
27c6333620 fix(sheets): resolve --sheet-name via title + keep bare sheet selectors verbatim
Two review findings on the backward-compat layer:

- lookupSheetIndex matched only sm["sheet_name"], but get_workbook_structure
  surfaces the sub-sheet display name as "title". Every --sheet-name path that
  relies on the lookup (e.g. +sheet-move) failed to resolve. Fall back to
  "title" when "sheet_name" is absent so either field resolves.

- +read / +write / +append fell back to --sheet-id when --range was omitted,
  then routed that bare sheet id through the range normalizer. A sheet id that
  looks A1-ish (letters+digits, e.g. "shtABC123") got mangled into
  "shtABC123!shtABC123:shtABC123". Split the sheet-only path from the
  range-normalization path: read/append pass the selector through verbatim,
  write builds the rect from the selector's A1.

Regression tests added for both paths; sheets suite green.
2026-06-03 14:25:43 +08:00
xiongyuanwen-byted
6bd21ac8c9 fix(cli): structured errors for unknown flags, print-schema, deprecated aliases
From the branch code-review doc (3 findings):

- pure-group UnknownFlags: installUnknownSubcommandGuard whitelists unknown
  flags so a mistyped subcommand still reaches the suggestion path, but a lone
  unknown flag before any subcommand (`sheets --badflag`) was swallowed and the
  group fell through to help + exit 0. unknownSubcommandRunE now recovers the
  swallowed tokens (from os.Args captured at Execute entry) and fails with a
  structured unknown_flag error; a misplaced but known flag (e.g. --format)
  still prints help.

- deprecated-alias notice: a backward-compat alias that fails a cobra-level
  required flag short-circuits before RunE, so the Validate/Execute-wrapped
  deprecation notice was dropped. Added Shortcut.OnInvoke, fired from PreRunE
  (ahead of ValidateRequiredFlags); and the root legacy error fallback now
  routes through the structured envelope when a deprecation is pending so the
  migration hint survives. Non-deprecated errors keep the plain output.

- --print-schema: runShortcut returned the bare error from PrintFlagSchema. It
  is now wrapped as a structured output.ExitError (type print_schema_error) so
  agent introspection can parse the failure.

Tests added for each path; cmd + sheets suites green.
2026-06-03 14:11:02 +08:00
xiongyuanwen-byted
e4309bb5b2 fix(sheets): harden batch type-checking and +workbook-create edge cases
From the branch code-review doc (3 findings):

- +batch-update sub-ops: `operations` is skipped by parse-time schema
  validation and mapFlagView coerces a type-mismatched scalar to its zero
  value, so "index":"abc" or "multiple":"true" silently became 0 / false and
  wrote to the wrong place. translateBatchOp now runs validateRawTypes, which
  checks each sub-op scalar against its flag-defs type and rejects mismatches.

- +workbook-create with empty arrays: buildInitialFillInput returned (nil,nil)
  for empty rows while the caller wrote fill["excel_id"] unconditionally, so
  --values '[]' panicked on a nil map and --headers '[]' produced an illegal
  "A1:1" range. It now also returns nil when no cells survive (maxCols==0
  guard) and Execute/DryRun skip the fill when fill==nil.

- +workbook-create partial failure: after the spreadsheet was created, a
  first-sheet lookup or fill failure returned a bare fmt.Errorf, losing the new
  token. It now returns a structured partial_success error carrying
  spreadsheet_token in the detail so callers can retry or clean up.

Tests added for each path; sheets suite green.
2026-06-03 14:10:59 +08:00
xiongyuanwen-byted
d6a0aadbe4 fix(cli): surface skill in deprecated_command notice
deprecation.Notice carries Skill, but the _notice.deprecated_command payload
dropped it, forcing callers to parse `message` to learn which skill to update.
Emit `skill` when set, alongside the existing `replacement`.
2026-06-03 12:37:25 +08:00
xiongyuanwen-byted
da198cf06a fix(sheets): enforce required-flag contract in batch sub-ops
Batch sub-ops reuse each shortcut's shared *Input builder through mapFlagView,
which seeds flag-defs defaults — so any required check that lives OUTSIDE the
builder (cobra MarkFlagsOneRequired, or a shortcut's own Validate) is silently
bypassed and the default value wins. Two gaps surfaced in PR review:

- +csv-put: with neither --start-cell nor --range set, start-cell's "A1"
  default won and the paste silently anchored at A1. Require an explicit anchor
  (guard on Changed, mirroring the standalone MarkFlagsOneRequired).
- +sheet-move: --index (plus >=0 bounds for index / source-index) was not
  enforced in the batch path; a missing --index silently moved the sheet to the
  front. Mirror SheetMove.Validate.

Also from the same review:
- +batch-update: an explicit --continue-on-error=false now wins over an
  --operations envelope's continue_on_error:true (guard on Changed, not value).
- validateDropdownRanges rejects malformed sheet!range ("!A1", "Sheet1!",
  "Sheet1!bad") at Validate instead of deferring to the server.

Tests added/updated for each path; full sheets suite green.
2026-06-03 12:37:25 +08:00
zhengzhijie
ad7b20935c fix(sheets): correct +dropdown-get sheet-locator doc, finalize skill to 2.0.0
+dropdown-get requires a mandatory sheet selector — its Validate calls
resolveSheetSelector — so drop it from the "no sheet locator" exception
list in SKILL.md. It was wrongly grouped with +dropdown-update/+dropdown-delete,
which take only --ranges. +dropdown-get's own per-shortcut badge (公共四件套)
was already correct. Also finalize the skill version 2.0.0-draft -> 2.0.0.
2026-06-03 12:33:58 +08:00
xiongyuanwen-byted
6e18185eaa chore(sheets): promote lark-sheets skill to 2.0.0
Drop the -draft suffix now that the refactored sheets skill is ready to
ship.
2026-06-03 10:34:49 +08:00
xiongyuanwen-byted
aec1bd4b0c chore: drop hardcoded ppe lane routing from base security headers
The x-tt-env/x-use-ppe headers forced every request onto the
ppe_moa_canvas pre-release lane; they were only meant for exercising the
sheets refactor against the staging backend. Remove them so the CLI
routes to production by default.
2026-06-02 21:25:57 +08:00
xiongyuanwen-byted
fe1b6b7bbb feat(sheets): flag pre-refactor backward aliases via _notice and --help grouping
Nudge users whose lark-sheets skill predates the refactor to migrate off
the pre-refactor aliases (+read, +write, ...), without requiring anyone
to read --help.

- internal/deprecation: process-level pending Notice slot (mirrors
  internal/skillscheck), surfaced in the JSON "_notice" envelope under a
  "deprecated_command" key.
- internal/cmdutil: shared DeprecatedGroupID cobra group + helper so both
  --help rendering and the unknown-subcommand path classify aliases the
  same way.
- shortcuts/register.go: applySheetsCompatGroups splits the aliases into a
  dedicated "update your skill" help group with "(-> +new)" pointers;
  wrapSheetsBackwardDeprecation records the notice from Validate/Execute so
  direct callers that never read --help still get flagged.
- cmd/root.go: extract composePendingNotice (now unit-testable) and split
  availableSubcommandNames into current vs deprecated buckets while still
  ranking unknown-subcommand suggestions across both.
2026-06-02 21:17:56 +08:00
zhengzhijie
4c24c6eb94 fix(sheets): resolve 30 golangci-lint v2.1.6 issues — copyloopvar, nilerr, unused
Removed 25 Go 1.22+ loop variable copies (copyloopvar) from test files where
tc := tc / tt := tt / c := c are no longer needed. Fixed 4 nilerr false
positives in flag_schema_validate.go by making intentional error discards
explicit (schema validation failures skip silently — best-effort guard).
Dropped unused batchOpDispatchKeys helper in batch_op_dispatch.go.
2026-06-02 20:23:05 +08:00
xiongyuanwen-byted
bc1cd72074 feat(sheets): restore pre-refactor shortcuts under backward/ for compatibility
The lark-sheets refactor renamed every shortcut (verb-noun → noun-verb,
e.g. +create-sheet → +sheet-create) and dropped the old commands. External
callers and the tests/cli_e2e/sheets suite still drive the legacy command
names (+create, +read, +write, +create-sheet, ...), which broke.

Re-add the pre-refactor implementations verbatim from main as an isolated
shortcuts/sheets/backward package (package rename only) and register
backward.Shortcuts() alongside sheets.Shortcuts(). Both sets mount under the
`sheets` service; their command names are fully disjoint (38 new vs 42 old,
zero overlap), so old and new commands coexist without collision.
2026-06-02 18:00:23 +08:00
xiongyuanwen-byted
d2aa27dac8 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-refactor
# Conflicts:
#	internal/cmdutil/factory_default.go
#	internal/transport/warn_test.go
#	internal/util/proxy.go
2026-06-02 17:53:17 +08:00
zgz2048
e57d97f341 docs: optimize base skill references (#1171) 2026-06-02 17:30:10 +08:00
xiongyuanwen-byted
3bca545796 docs(sheets): sync skill description from spec (cloud-drive alias, lark-drive search, doubao routing) 2026-06-02 17:05:03 +08:00
MaxHuang22
57ba4fae61 feat: unconditionally inject --format flag for all shortcuts (#1156)
* feat: unconditionally inject --format flag for all shortcuts

Removes three HasFormat guards in runner.go so every shortcut
gets --format regardless of the Shortcut.HasFormat field value.
Shortcuts that already define a custom 'format' flag in Flags[]
are skipped to avoid redefinition panics (e.g. mail +triage, +watch).
HasFormat is retained in the struct but marked deprecated.

Change-Id: I5e8fe07e839d5aed4cefaf7d753dabbaee68fb6e

* test: isolate config dir in format-universal test

Change-Id: I3a59942aa8a6753cd949ca42f2a19a72f032ff55

* test: revert unnecessary config-dir isolation (mount-only test)

Change-Id: I0146e5a2f57f5419863bdeeaa1a662fd8f70bddf
2026-06-02 16:55:02 +08:00
YH-1600
925ae5ecd6 docs: add lark drive knowledge organization workflow (#1028)
Change-Id: I2343fcdf26ceefb898cc8d4faeae4b17384cfea8
2026-06-02 16:28:25 +08:00
liangshuo-1
4710a294f5 refactor(transport): own all HTTP transport in internal/transport, fix util layering inversion (#1213)
internal/util imported internal/proxyplugin (SharedTransport, FallbackTransport,
NewHTTPClient, and WarnIfProxied via proxyPluginStatus), so a foundational util
package depended up into a feature package, pulling binding/core/vfs into the
transitive cone of every util importer.

Move internal/proxyplugin -> internal/transport and make it the single owner of
outbound transport: fold the two SharedTransport functions into one Shared()
(proxy-plugin override -> LARK_CLI_NO_PROXY -> http.DefaultTransport), and move
Fallback/NewHTTPClient/WarnIfProxied/DetectProxyEnv/noProxyTransport out of the
now-deleted internal/util/proxy.go into the new package. The proxy-plugin probe
is demoted to a private pluginTransport(); the duplicate redactProxyURL collapses
to one. internal/util keeps no proxy code and is a leaf again.

Re-point all consumers (registry, doctor, config, auth, cmdutil, update) to
internal/transport. Behavior-preserving: package move + symbol rename + dedup.
Two new tests lock the fail-closed contract (plugin overrides NO_PROXY; malformed
config never falls through to direct egress).
2026-06-02 16:10:35 +08:00
243 changed files with 14522 additions and 5940 deletions

View File

@@ -16,7 +16,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
)
// configInitResult holds the result of the interactive config init flow.
@@ -179,7 +179,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
// Step 1: Request app registration (begin)
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := util.NewHTTPClient(0)
httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)

View File

@@ -19,8 +19,8 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/internal/util"
)
// DoctorOptions holds inputs for the doctor command.
@@ -155,7 +155,7 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
// the real egress path (and are blocked when proxy plugin fails closed).
httpClient := util.NewHTTPClient(0)
httpClient := transport.NewHTTPClient(0)
mcpURL := ep.MCP + "/mcp"
type probeResult struct {

61
cmd/notice_test.go Normal file
View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/deprecation"
)
// composePendingNotice must surface a deprecated-command alias under the
// "deprecated_command" key, with the migration target and a skill-update hint,
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
// without ever reading --help.
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(&deprecation.Notice{
Command: "+read",
Replacement: "+cells-get",
Skill: "lark-sheets",
})
got := composePendingNotice()
if got == nil {
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
}
entry, ok := got["deprecated_command"].(map[string]interface{})
if !ok {
t.Fatalf("missing deprecated_command key: %#v", got)
}
if entry["command"] != "+read" {
t.Errorf("command = %v, want +read", entry["command"])
}
if entry["replacement"] != "+cells-get" {
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
}
if entry["skill"] != "lark-sheets" {
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
}
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
t.Errorf("message missing skill-update hint: %q", msg)
}
}
// With nothing pending, the provider returns nil so no "_notice" field is
// emitted on a clean run.
func TestComposePendingNoticeEmpty(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
if got := composePendingNotice(); got != nil {
// update/skills pending are process-global; only assert the absence of
// our own key to stay robust against unrelated pending state.
if _, ok := got["deprecated_command"]; ok {
t.Fatalf("deprecated_command present after clear: %#v", got)
}
}
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook"
@@ -85,7 +86,15 @@ COMMUNITY:
More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
// from here. It stays nil in unit tests that invoke a RunE directly with
// explicit args — correct, since those don't exercise the whitelist path.
var rawInvocationArgs []string
func Execute() int {
rawInvocationArgs = os.Args[1:]
inv, err := BootstrapInvocationContext(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
@@ -149,29 +158,49 @@ func setupNotices() {
skillscheck.Init(build.Version)
// Composed notice provider — emits keys only when each pending is set.
output.PendingNotice = func() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
output.PendingNotice = composePendingNotice
}
// composePendingNotice merges all process-level pending notices (available
// update, skills/binary drift, deprecated-command alias) into the map surfaced
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
// Extracted from Execute so the composition is unit-testable.
func composePendingNotice() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if len(notice) == 0 {
return nil
}
return notice
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if dep := deprecation.GetPending(); dep != nil {
entry := map[string]interface{}{
"command": dep.Command,
"message": dep.Message(),
"action": "lark-cli update",
}
if dep.Replacement != "" {
entry["replacement"] = dep.Replacement
}
if dep.Skill != "" {
entry["skill"] = dep.Skill
}
notice["deprecated_command"] = entry
}
if len(notice) == 0 {
return nil
}
return notice
}
// isCompletionCommand returns true if args indicate a shell completion request.
@@ -269,6 +298,19 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return exitErr.Code
}
// A backward-compat alias records its deprecation notice in PreRunE, which
// runs before cobra's required-flag validation — but a missing required flag
// fails before RunE and lands here, where the bare "Error:" line would drop
// the notice. When a deprecation is pending, route through the structured
// envelope so the migration hint still reaches the caller; all other errors
// keep the existing plain output.
if deprecation.GetPending() != nil {
output.WriteErrorEnvelope(errOut, &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
}, string(f.ResolvedIdentity))
return 1
}
fmt.Fprintln(errOut, "Error:", err)
return 1
}
@@ -335,35 +377,118 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
// they have moved to the typed surface.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// A bare group (e.g. `sheets`) legitimately prints help. But an unknown
// flag placed before any subcommand (`sheets --badflag`) is whitelisted
// away by installUnknownSubcommandGuard, which also leaves args empty —
// without this check it would silently fall through to help + exit 0.
// Recover the swallowed flag tokens and fail structured so agents (and
// the flagDidYouMean contract) still see a real error.
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
},
},
}
}
return cmd.Help()
}
unknown := args[0]
available := availableSubcommandNames(cmd)
suggestions := suggest.Closest(unknown, available, 6)
available, deprecated := availableSubcommandNames(cmd)
// Rank suggestions across both current and deprecated names so a mistyped
// legacy command (e.g. +raed → +read) still resolves; the alias stays
// runnable and self-flags via the _notice on execution.
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
}
detail := map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
}
// Only services with backward-compat aliases (currently sheets) carry a
// deprecated bucket; omit the key elsewhere so every other service's
// envelope is unchanged.
if len(deprecated) > 0 {
detail["deprecated"] = deprecated
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
},
Detail: detail,
},
}
}
func availableSubcommandNames(cmd *cobra.Command) []string {
subs := make([]string, 0, len(cmd.Commands()))
// unknownFlagTokens returns the -/-- tokens in rawArgs that cmd does not define.
// installUnknownSubcommandGuard whitelists unknown flags on pure groups so a
// mistyped subcommand still reaches the suggestion path; the side effect is that
// a lone unknown flag (no subcommand) is swallowed, leaving the group to fall
// through to help. This recovers those tokens so the caller can fail structured.
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var unknown []string
for _, a := range rawArgs {
if a == "--" {
break // everything after -- is positional
}
if len(a) < 2 || a[0] != '-' {
continue
}
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name != "" && !flagDefinedInTree(cmd, name) {
unknown = append(unknown, a)
}
}
return unknown
}
// flagDefinedInTree reports whether name is defined on cmd, its inherited
// (persistent) flags, or any direct subcommand. The subcommand case covers a
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
// --format is injected on every leaf shortcut, not on the group — so only a
// genuinely unknown flag like `sheets --badflag` is reported.
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
short := len(name) == 1
known := func(c *cobra.Command, inherited bool) bool {
fs := c.Flags()
if inherited {
fs = c.InheritedFlags()
}
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
if known(cmd, false) || known(cmd, true) {
return true
}
for _, c := range cmd.Commands() {
if known(c, false) {
return true
}
}
return false
}
// availableSubcommandNames returns the invokable subcommand names of cmd, split
// into current commands and backward-compatibility aliases (those tagged into
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
// sorted; hidden commands plus help/completion are omitted.
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() {
continue
@@ -372,10 +497,15 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
if name == "help" || name == "completion" {
continue
}
subs = append(subs, name)
if cmdutil.IsDeprecatedCommand(c) {
deprecated = append(deprecated, name)
} else {
available = append(available, name)
}
}
sort.Strings(subs)
return subs
sort.Strings(available)
sort.Strings(deprecated)
return available, deprecated
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It

View File

@@ -21,6 +21,7 @@ import (
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
)
@@ -268,6 +269,54 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
// backward-compat alias that fails on a cobra-level required flag (which
// short-circuits before RunE) still routes through the structured envelope,
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
// switches to WriteErrorEnvelope when a deprecation is pending — so the
// migration notice is no longer dropped on the plain "Error:" line.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// nor an *output.ExitError, so it reaches the legacy fallback.
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
}
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
}
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
// fix does not reshape every unrecognized cobra error.
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
if !strings.HasPrefix(errOut.String(), "Error:") {
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
}
}
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
// stderr write fails mid-envelope, handleRootError still returns the typed
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -72,6 +73,60 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
}
}
func TestUnknownFlagTokens(t *testing.T) {
_, drive, _ := newGroupTree()
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
// the subcommand) is distinguished from a genuinely unknown one.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
cases := []struct {
name string
rawArgs []string
want []string
}{
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
{"no flags at all", []string{"drive"}, nil},
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := unknownFlagTokens(drive, tc.rawArgs)
if len(got) != len(tc.want) {
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
// --badflag, so RunE sees no args; the guard must recover it from
// rawInvocationArgs and fail structured rather than print help + exit 0.
rawInvocationArgs = []string{"drive", "--badflag"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
}
if !strings.Contains(err.Error(), "unknown flag") {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
}
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
@@ -164,7 +219,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
)
got := availableSubcommandNames(root)
got, _ := availableSubcommandNames(root)
want := []string{"alpha", "gamma"}
if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got)
@@ -175,3 +230,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
}
}
}
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
root.AddCommand(
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
available, deprecated := availableSubcommandNames(root)
if len(available) != 1 || available[0] != "+new-cmd" {
t.Errorf("available = %v, want [+new-cmd]", available)
}
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
}
}
// unknownSubcommandRunE must split current vs deprecated subcommands into
// separate detail buckets, while suggestions still rank across both so a
// mistyped legacy alias resolves.
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
t.Errorf("available = %v, want [+cells-get]", available)
}
deprecated, ok := detail["deprecated"].([]string)
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
t.Errorf("deprecated = %v, want [+read]", deprecated)
}
// suggestions rank across both buckets: "+reat" is closest to +read.
suggestions, _ := detail["suggestions"].([]string)
found := false
for _, s := range suggestions {
if s == "+read" {
found = true
}
}
if !found {
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
}
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
)
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
@@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return util.FallbackTransport()
return transport.Fallback()
}
// RoundTrip implements http.RoundTripper.

View File

@@ -23,7 +23,7 @@ import (
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
@@ -102,15 +102,15 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
util.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
transport.WarnIfProxied(f.IOStreams.ErrOut)
var transport http.RoundTripper = util.SharedTransport()
transport = &RetryTransport{Base: transport}
transport = &SecurityHeaderTransport{Base: transport}
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
transport = wrapWithExtension(transport)
var rt http.RoundTripper = transport.Shared()
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
rt = wrapWithExtension(rt)
client := &http.Client{
Transport: transport,
Transport: rt,
Timeout: 30 * time.Second,
CheckRedirect: safeRedirectPolicy,
}
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
util.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
transport.WarnIfProxied(f.IOStreams.ErrOut)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
@@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = util.SharedTransport()
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import "github.com/spf13/cobra"
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
// command — one kept alive for users whose skill predates a refactor. Service
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
// rendering and unknown-subcommand suggestions read it to separate these
// aliases from the current commands.
const DeprecatedGroupID = "deprecated"
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
func IsDeprecatedCommand(c *cobra.Command) bool {
return c != nil && c.GroupID == DeprecatedGroupID
}

View File

@@ -9,7 +9,7 @@ import (
"time"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
)
// RetryTransport is an http.RoundTripper that retries on 5xx responses
@@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return util.FallbackTransport()
return transport.Fallback()
}
func (t *RetryTransport) delay() time.Duration {
@@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return util.FallbackTransport().RoundTrip(req)
return transport.Fallback().RoundTrip(req)
}
// BuildHeaderTransport is an http.RoundTripper that force-writes the
@@ -87,7 +87,7 @@ func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, err
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return util.FallbackTransport().RoundTrip(req)
return transport.Fallback().RoundTrip(req)
}
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
@@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return util.FallbackTransport()
return transport.Fallback()
}
// RoundTrip implements http.RoundTripper.

View File

@@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
// the transport still sets X-Cli-Build and routes the request through
// util.FallbackTransport rather than panicking. This covers the fallback
// transport.Fallback rather than panicking. This covers the fallback
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
var receivedBuild string

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deprecation carries a process-level notice that the command currently
// being executed is a backward-compatibility alias, kept alive for users whose
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
//
// A CLI process runs exactly one shortcut, so a single process-level slot is
// sufficient: the command's Execute records the notice before producing output,
// and the output layer reads it back when building the envelope.
package deprecation
import (
"strings"
"sync/atomic"
)
// Notice describes a deprecated command alias and the current command that
// replaces it. Replacement and Skill are optional.
type Notice struct {
Command string `json:"command"`
Replacement string `json:"replacement,omitempty"`
Skill string `json:"skill,omitempty"`
}
// Message returns a single-line, AI-agent-parseable description of the alias
// plus the canonical fix (update the skill). Mirrors the style of
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
func (n *Notice) Message() string {
var b strings.Builder
b.WriteString(n.Command)
b.WriteString(" is a pre-refactor compatibility alias")
if n.Replacement != "" {
b.WriteString("; use ")
b.WriteString(n.Replacement)
b.WriteString(" instead")
}
if n.Skill != "" {
b.WriteString("; update your ")
b.WriteString(n.Skill)
b.WriteString(" skill, run: lark-cli update")
} else {
b.WriteString("; update your skill, run: lark-cli update")
}
return b.String()
}
// pending stores the latest deprecation notice for the current process.
var pending atomic.Pointer[Notice]
// SetPending stores the notice for consumption by output decorators.
// Pass nil to clear.
func SetPending(n *Notice) { pending.Store(n) }
// GetPending returns the pending deprecation notice, or nil.
func GetPending() *Notice { return pending.Load() }

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package deprecation
import "testing"
func TestNoticeMessage(t *testing.T) {
tests := []struct {
name string
notice Notice
want string
}{
{
name: "replacement and skill",
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
},
{
name: "no replacement",
notice: Notice{Command: "+read", Skill: "lark-sheets"},
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
},
{
name: "no skill",
notice: Notice{Command: "+read", Replacement: "+cells-get"},
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.notice.Message(); got != tt.want {
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
}
})
}
}
func TestSetGetPending(t *testing.T) {
t.Cleanup(func() { SetPending(nil) })
SetPending(nil)
if got := GetPending(); got != nil {
t.Fatalf("expected nil pending after clear, got %#v", got)
}
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
SetPending(n)
got := GetPending()
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
t.Fatalf("GetPending() = %#v, want %#v", got, n)
}
SetPending(nil)
if GetPending() != nil {
t.Fatal("expected nil after clearing")
}
}

View File

@@ -17,7 +17,7 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -181,7 +181,7 @@ func saveCachedMerged(data []byte, meta CacheMeta) error {
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
// Route through the shared proxy-plugin-aware transport so remote API
// definition fetches honor proxy plugin mode instead of bypassing it.
client := util.NewHTTPClient(fetchTimeout)
client := transport.NewHTTPClient(fetchTimeout)
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
if err != nil {
return nil, nil, err

View File

@@ -1,14 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package proxyplugin implements the ~/.lark-cli/proxy_config.json based security proxy plugin mode.
// Package transport owns how the CLI assembles its outbound HTTP transport: the
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
//
// It supports:
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
//
// Environment variables override matching values from proxy_config.json.
package proxyplugin
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
// proxy, optionally trusting an extra root CA PEM bundle for TLS-inspection
// proxies, and fails closed on misconfiguration. Environment variables override
// matching values from proxy_config.json.
package transport
import (
"encoding/json"
@@ -222,21 +223,6 @@ func (c *Config) proxyURL() (*url.URL, error) {
return u, nil
}
// redactProxyURL masks userinfo (username:password) in a proxy URL.
// Handles both scheme-prefixed ("http://user:pass@host") and bare formats.
func redactProxyURL(raw string) string {
u, err := url.Parse(raw)
if err == nil && u.User != nil {
u.User = url.User("***")
return u.String()
}
// Fallback: handle "user:pass@proxy:8080"
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]
}
return raw
}
// ApplyToTransport clones base and applies proxy plugin settings to the clone.
// Caller owns the returned *http.Transport.
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package proxyplugin
package transport
import (
"net/http"

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"net/http"
"os"
"sync"
"time"
)
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
//
// Precedence (highest first):
// 1. proxy-plugin mode — force traffic through a fixed loopback proxy;
// FAIL-CLOSED when the plugin config exists but is invalid.
// 2. LARK_CLI_NO_PROXY — direct egress, proxy disabled.
// 3. http.DefaultTransport — the stdlib process-wide singleton (honors
// HTTP(S)_PROXY), so every client shares one connection pool / TLS cache.
//
// The returned RoundTripper MUST NOT be mutated. Callers that need a customized
// transport should assert to *http.Transport and Clone() it. A shared base is
// required so persistConn read/write goroutines are reused; cloning per call
// leaks them until IdleConnTimeout (~90s) fires.
func Shared() http.RoundTripper {
// Proxy-plugin mode overrides everything, INCLUDING LARK_CLI_NO_PROXY. When
// the plugin config exists but is invalid, pluginTransport returns a
// fail-closed transport with ok=true and we return it here — we MUST NOT
// fall through to the NO_PROXY / DefaultTransport direct-egress paths below.
if t, ok := pluginTransport(); ok {
return t
}
if os.Getenv(EnvNoProxy) != "" {
return noProxyTransport()
}
return http.DefaultTransport
}
// Fallback returns a shared *http.Transport. It is a thin wrapper over Shared
// retained so modules already on the leak-free singleton path (internal/auth,
// internal/cmdutil transport decorators) do not have to migrate. New code
// should prefer Shared and treat the base as an http.RoundTripper.
//
// Fail-closed invariant: pluginTransport always expresses its blocked transport
// as a concrete *http.Transport (see failClosedTransport), so the assertion
// below preserves the block. The noProxyTransport() fallback is therefore only
// reached when no proxy plugin is configured and some external code replaced
// http.DefaultTransport with a non-*http.Transport — a case with no fail-closed
// intent, where a proxy-disabled transport is acceptable.
func Fallback() *http.Transport {
if t, ok := Shared().(*http.Transport); ok {
return t
}
return noProxyTransport()
}
// NewHTTPClient returns an *http.Client whose Transport is the shared,
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
// for outbound requests: a bare client falls back to http.DefaultTransport and
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
// fail-closed), creating an audit blind spot.
//
// A zero timeout means no client-level timeout (callers relying on context
// deadlines pass 0).
func NewHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: Shared(),
Timeout: timeout,
}
}
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
// built the first time LARK_CLI_NO_PROXY is observed set.
var noProxyTransport = sync.OnceValue(func() *http.Transport {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}
}
t := def.Clone()
t.Proxy = nil
return t
})

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"net/http"
"net/url"
"testing"
"time"
)
// TestShared_DefaultReturnsStdlibSingleton verifies the default shared transport.
func TestShared_DefaultReturnsStdlibSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
if Shared() != http.DefaultTransport {
t.Error("Shared should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
}
}
// TestShared_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
func TestShared_NoProxyReturnsClone(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "1")
tr := Shared()
if tr == http.DefaultTransport {
t.Fatal("Shared should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
}
ht, ok := tr.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", tr)
}
if ht.Proxy != nil {
t.Error("no-proxy transport should have Proxy == nil")
}
}
// TestShared_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
func TestShared_NoProxyIsCachedSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "1")
if Shared() != Shared() {
t.Error("repeated Shared calls with LARK_CLI_NO_PROXY set must return the same instance")
}
}
// TestShared_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib
// transport after unsetting LARK_CLI_NO_PROXY.
func TestShared_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
// the no-proxy singleton), then unsets it. Subsequent calls must return
// http.DefaultTransport, NOT the cached no-proxy clone.
t.Setenv(EnvNoProxy, "1")
if Shared() == http.DefaultTransport {
t.Fatal("precondition: first call with env set should not return DefaultTransport")
}
t.Setenv(EnvNoProxy, "")
if after := Shared(); after != http.DefaultTransport {
t.Errorf("after unsetting LARK_CLI_NO_PROXY, Shared must return http.DefaultTransport, got %T", after)
}
}
// TestShared_NoProxyOverridesSystemProxy verifies that LARK_CLI_NO_PROXY disables system proxies.
func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
ht, ok := Shared().(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", Shared())
}
if ht.Proxy != nil {
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
}
}
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
// transport (instead of a bare client that bypasses proxy plugin mode).
func TestNewHTTPClient(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
c := NewHTTPClient(7 * time.Second)
if c.Transport == nil {
t.Fatal("NewHTTPClient transport is nil; want shared transport")
}
if c.Transport != Shared() {
t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport)
}
if c.Timeout != 7*time.Second {
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
}
}
// TestShared_PluginOverridesNoProxy locks the contract that proxy-plugin mode wins
// over LARK_CLI_NO_PROXY: even with NO_PROXY set, an enabled plugin forces the proxy.
func TestShared_PluginOverridesNoProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
t.Setenv(EnvNoProxy, "1") // NO_PROXY set, but the plugin must win
resetProxyPluginState()
writeFile(t, Path(), []byte(`{
"LARKSUITE_CLI_PROXY_ENABLE": true,
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128"
}`), 0600)
tr, ok := Shared().(*http.Transport)
if !ok {
t.Fatalf("Shared() = %T, want proxy *http.Transport, not the NO_PROXY clone", tr)
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil || u == nil || u.String() != "http://127.0.0.1:3128" {
t.Fatalf("Proxy() = %v, %v; plugin must override NO_PROXY with the fixed proxy", u, err)
}
}
// TestShared_MalformedConfigFailsClosedEvenWithNoProxy locks the most dangerous
// invariant of the fold: a malformed proxy_config.json must FAIL CLOSED, never
// fall through to direct egress — not even to the LARK_CLI_NO_PROXY clone.
func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
t.Setenv(EnvNoProxy, "1")
resetProxyPluginState()
writeFile(t, Path(), []byte(`{`), 0600) // malformed
rt := Shared()
if rt == http.DefaultTransport {
t.Fatal("malformed config returned http.DefaultTransport — fail OPEN")
}
if rt == noProxyTransport() {
t.Fatal("malformed config fell through to the NO_PROXY direct-egress clone — fail OPEN")
}
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err == nil {
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
}
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package proxyplugin
package transport
import (
"crypto/tls"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package proxyplugin
package transport
import (
"crypto/rand"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package proxyplugin
package transport
import (
"fmt"
@@ -16,7 +16,7 @@ var proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport)
// cachedBlockedTransport is a fail-closed transport cached on first use when
// the proxy plugin config exists but is invalid. This avoids cloning
// http.DefaultTransport on every SharedTransport call.
// http.DefaultTransport on every pluginTransport call.
var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
func buildBlockedTransport() http.RoundTripper {
@@ -28,7 +28,7 @@ func buildProxyPluginTransport() http.RoundTripper {
if !ok {
// Cannot clone the stdlib transport. Fail closed with a concrete
// *http.Transport (not a bare RoundTripper) so downcasting callers such
// as util.FallbackTransport cannot silently degrade this into a
// as Fallback cannot silently degrade this into a
// direct-egress transport.
return failClosedTransport(fmt.Errorf("proxy plugin transport unavailable: http.DefaultTransport is %T, want *http.Transport", http.DefaultTransport))
}
@@ -51,9 +51,9 @@ func buildProxyPluginTransport() http.RoundTripper {
return t
}
// SharedTransport returns the proxy plugin transport when proxy plugin mode is
// pluginTransport returns the proxy plugin transport when proxy plugin mode is
// configured. The bool return is false when the plugin is not configured or not enabled.
func SharedTransport() (http.RoundTripper, bool) {
func pluginTransport() (http.RoundTripper, bool) {
cfg, err := Load()
if err != nil {
return cachedBlockedTransport(), true
@@ -68,7 +68,7 @@ func SharedTransport() (http.RoundTripper, bool) {
// err. It clones http.DefaultTransport when possible (preserving dial/timeout
// tuning); otherwise it builds a minimal transport. Returning a concrete
// *http.Transport (rather than a bare RoundTripper) is required so downcasting
// callers such as util.FallbackTransport cannot silently degrade a fail-closed
// callers such as Fallback cannot silently degrade a fail-closed
// signal into a direct-egress transport.
func failClosedTransport(err error) *http.Transport {
if def, ok := http.DefaultTransport.(*http.Transport); ok {

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package proxyplugin
package transport
import (
"io"
@@ -20,21 +20,21 @@ func resetProxyPluginState() {
cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
}
func TestSharedTransport_NotConfigured(t *testing.T) {
func TestPluginTransport_NotConfigured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
tr, ok := SharedTransport()
tr, ok := pluginTransport()
if ok {
t.Fatalf("SharedTransport() ok = true, want false")
t.Fatalf("pluginTransport() ok = true, want false")
}
if tr != nil {
t.Fatalf("SharedTransport() transport = %T, want nil", tr)
t.Fatalf("pluginTransport() transport = %T, want nil", tr)
}
}
func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
@@ -46,13 +46,13 @@ func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
"LARKSUITE_CLI_CA_PATH": ""
}`), 0600)
rt, ok := SharedTransport()
rt, ok := pluginTransport()
if !ok {
t.Fatal("SharedTransport() ok = false, want true")
t.Fatal("pluginTransport() ok = false, want true")
}
tr, ok := rt.(*http.Transport)
if !ok {
t.Fatalf("SharedTransport() = %T, want *http.Transport", rt)
t.Fatalf("pluginTransport() = %T, want *http.Transport", rt)
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
@@ -63,7 +63,7 @@ func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
}
}
func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
@@ -72,12 +72,12 @@ func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
writeFile(t, Path(), []byte(`{`), 0600)
rt, ok := SharedTransport()
rt, ok := pluginTransport()
if !ok {
t.Fatal("SharedTransport() ok = false, want true")
t.Fatal("pluginTransport() ok = false, want true")
}
if rt == http.DefaultTransport {
t.Fatalf("SharedTransport() returned http.DefaultTransport, want fail-closed transport")
t.Fatalf("pluginTransport() returned http.DefaultTransport, want fail-closed transport")
}
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err == nil {
@@ -88,23 +88,23 @@ func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
}
}
func TestSharedTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
func TestPluginTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
writeFile(t, Path(), []byte(`{`), 0600)
a, ok := SharedTransport()
a, ok := pluginTransport()
if !ok {
t.Fatal("SharedTransport() ok = false, want true")
t.Fatal("pluginTransport() ok = false, want true")
}
b, ok := SharedTransport()
b, ok := pluginTransport()
if !ok {
t.Fatal("SharedTransport() ok = false, want true")
t.Fatal("pluginTransport() ok = false, want true")
}
if a != b {
t.Fatalf("SharedTransport() returned different instances on repeated calls; blocked transport must be cached")
t.Fatalf("pluginTransport() returned different instances on repeated calls; blocked transport must be cached")
}
}
@@ -148,13 +148,13 @@ func TestBuildProxyPluginTransport_NonTransportDefaultFailsClosed(t *testing.T)
}
}
// TestSharedTransport_InvalidConfigBlockerIsConcreteTransport guards the
// fail-closed invariant that util.FallbackTransport relies on: even when
// TestPluginTransport_InvalidConfigBlockerIsConcreteTransport guards the
// fail-closed invariant that Fallback relies on: even when
// http.DefaultTransport is not an *http.Transport, an invalid proxy config must
// produce a blocked transport that is itself a concrete *http.Transport. If it
// were a bare RoundTripper, util.FallbackTransport would downcast-fail and
// were a bare RoundTripper, Fallback would downcast-fail and
// silently degrade it into a direct-egress transport.
func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
@@ -163,12 +163,12 @@ func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
writeFile(t, Path(), []byte(`{`), 0600)
rt, ok := SharedTransport()
rt, ok := pluginTransport()
if !ok {
t.Fatal("SharedTransport() ok = false, want true")
t.Fatal("pluginTransport() ok = false, want true")
}
if _, isTransport := rt.(*http.Transport); !isTransport {
t.Fatalf("SharedTransport() blocked transport = %T, want *http.Transport so FallbackTransport cannot degrade it to direct egress", rt)
t.Fatalf("pluginTransport() blocked transport = %T, want *http.Transport so Fallback cannot degrade it to direct egress", rt)
}
// Must remain fail-closed.
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})

104
internal/transport/warn.go Normal file
View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"fmt"
"io"
"net/url"
"os"
"strings"
"sync"
"github.com/larksuite/cli/internal/envvars"
)
// Proxy environment constants control shared transport proxy behavior.
const (
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
EnvNoProxy = "LARK_CLI_NO_PROXY"
)
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
var proxyEnvKeys = []string{
"HTTPS_PROXY", "https_proxy",
"HTTP_PROXY", "http_proxy",
"ALL_PROXY", "all_proxy",
}
// DetectProxyEnv returns the first proxy-related environment variable that is set,
// or empty strings if none are configured.
func DetectProxyEnv() (key, value string) {
for _, k := range proxyEnvKeys {
if v := os.Getenv(k); v != "" {
return k, v
}
}
return "", ""
}
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
var proxyWarningOnce sync.Once
// proxyPluginStatus reports the configured proxy plugin address, the extra
// trusted CA path (if any), and whether proxy plugin mode is enabled. It is
// indirected through a package variable so tests can simulate plugin-enabled
// mode without the process-global Load() sync.Once cache.
var proxyPluginStatus = func() (addr, caPath string, enabled bool) {
cfg, err := Load()
if err != nil || !cfg.Enabled() {
return "", "", false
}
return cfg.Proxy, cfg.CAPath, true
}
// redactProxyURL masks userinfo (username:password) in a proxy URL.
// Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats.
func redactProxyURL(raw string) string {
// Try standard url.Parse first (works when scheme is present)
u, err := url.Parse(raw)
if err == nil && u.User != nil {
return u.Scheme + "://***@" + u.Host + u.RequestURI()
}
// Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080")
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]
}
return raw
}
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
// are redacted. Safe to call multiple times; only the first call prints.
func WarnIfProxied(w io.Writer) {
proxyWarningOnce.Do(func() {
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
// Shared), so its warning and disable instructions take precedence.
// Emitting the env-proxy warning here would be misleading: it tells the
// user to set LARK_CLI_NO_PROXY=1, which does NOT disable the plugin proxy.
if addr, caPath, enabled := proxyPluginStatus(); enabled {
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n",
redactProxyURL(addr), envvars.CliProxyEnable, Path())
if strings.TrimSpace(caPath) != "" {
// A custom CA means upstream TLS can be intercepted/inspected by
// the proxy (MITM). Surface it so the operator is aware traffic
// (including Bearer tokens) is decryptable on this host.
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n",
caPath)
}
return
}
if os.Getenv(EnvNoProxy) != "" {
return
}
key, val := DetectProxyEnv()
if key == "" {
return
}
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
key, redactProxyURL(val), EnvNoProxy)
})
}

View File

@@ -1,44 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package util
package transport
import (
"bytes"
"net/http"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/larksuite/cli/internal/envvars"
)
// unsetEnv clears key for the duration of the test and restores its original value.
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, had := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() {
if had {
_ = os.Setenv(key, old)
} else {
_ = os.Unsetenv(key)
}
})
}
// unsetProxyPluginEnv clears proxy-related environment variables for deterministic tests.
func unsetProxyPluginEnv(t *testing.T) {
t.Helper()
// Ensure developer machine env doesn't accidentally enable proxy plugin mode
// and change expectations for SharedTransport().
unsetEnv(t, envvars.CliProxyEnable)
unsetEnv(t, envvars.CliProxyAddress)
unsetEnv(t, envvars.CliCAPath)
}
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
func TestDetectProxyEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -61,94 +34,17 @@ func TestDetectProxyEnv(t *testing.T) {
}
}
// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport.
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
t.Setenv(EnvNoProxy, "")
tr := SharedTransport()
if tr != http.DefaultTransport {
t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
}
}
// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
t.Setenv(EnvNoProxy, "1")
tr := SharedTransport()
if tr == http.DefaultTransport {
t.Fatal("SharedTransport should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
}
ht, ok := tr.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", tr)
}
if ht.Proxy != nil {
t.Error("no-proxy transport should have Proxy == nil")
}
}
// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
t.Setenv(EnvNoProxy, "1")
a := SharedTransport()
b := SharedTransport()
if a != b {
t.Error("repeated SharedTransport calls with LARK_CLI_NO_PROXY set must return the same instance")
}
}
// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy.
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
// the no-proxy singleton), then unsets it. Subsequent calls must return
// http.DefaultTransport, NOT the cached no-proxy clone.
t.Setenv(EnvNoProxy, "1")
noProxy := SharedTransport()
if noProxy == http.DefaultTransport {
t.Fatal("precondition: first call with env set should not return DefaultTransport")
}
t.Setenv(EnvNoProxy, "")
after := SharedTransport()
if after != http.DefaultTransport {
t.Errorf("after unsetting LARK_CLI_NO_PROXY, SharedTransport must return http.DefaultTransport, got %T (%p)", after, after)
}
}
// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies.
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
ht, ok := SharedTransport().(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", SharedTransport())
}
if ht.Proxy != nil {
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
}
}
// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
func TestWarnIfProxied_WithProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
// Reset the once guard for this test
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
out := buf.String()
if out == "" {
@@ -166,6 +62,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
for _, k := range proxyEnvKeys {
@@ -173,41 +70,25 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
}
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
}
}
func TestWarnIfProxied_SilentWhenNonInteractive(t *testing.T) {
proxyWarningOnce = sync.Once{}
// Non-interactive (interactive=false) mirrors agent / CI / piped invocations
// where stdin is not a TTY. The proxy warning must be suppressed so callers
// that parse stdout as JSON — often merging streams with `2>&1` — are not
// corrupted by a stray stderr line.
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
var buf bytes.Buffer
WarnIfProxied(&buf, false)
if buf.Len() != 0 {
t.Errorf("expected no warning in non-interactive mode, got: %s", buf.String())
}
}
// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings.
// TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings.
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
t.Setenv(EnvNoProxy, "1")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
@@ -218,15 +99,16 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTP_PROXY", "http://proxy:1234")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
first := buf.String()
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
second := buf.String()
if first == "" {
@@ -255,7 +137,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
t.Setenv(EnvNoProxy, "1")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
out := buf.String()
if !strings.Contains(out, "127.0.0.1:3128") {
@@ -274,7 +156,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
}
// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is
// trusted, the warning surfaces the TLS-interception capability (V3).
// trusted, the warning surfaces the TLS-interception capability.
func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
@@ -287,7 +169,7 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
t.Cleanup(func() { proxyPluginStatus = old })
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
out := buf.String()
if !strings.Contains(out, "custom CA") {
@@ -301,25 +183,6 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
}
}
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
// transport (instead of a bare client that bypasses proxy plugin mode).
func TestNewHTTPClient(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
t.Setenv(EnvNoProxy, "")
c := NewHTTPClient(7 * time.Second)
if c.Transport == nil {
t.Fatal("NewHTTPClient transport is nil; want shared transport")
}
if c.Transport != SharedTransport() {
t.Errorf("NewHTTPClient transport = %v, want SharedTransport()", c.Transport)
}
if c.Timeout != 7*time.Second {
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
}
}
// TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin
// warning never leaks credentials embedded in the configured proxy address.
func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
@@ -332,7 +195,7 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
t.Cleanup(func() { proxyPluginStatus = old })
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
out := buf.String()
if strings.Contains(out, "s3cret") {
@@ -348,8 +211,6 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
func TestRedactProxyURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
tests := []struct {
input string
want string
@@ -376,12 +237,13 @@ func TestRedactProxyURL(t *testing.T) {
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
var buf bytes.Buffer
WarnIfProxied(&buf, true)
WarnIfProxied(&buf)
out := buf.String()
if bytes.Contains([]byte(out), []byte("s3cret")) {

View File

@@ -17,7 +17,7 @@ import (
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -64,7 +64,7 @@ func httpClient() *http.Client {
}
return &http.Client{
Timeout: fetchTimeout,
Transport: util.SharedTransport(),
Transport: transport.Shared(),
}
}

View File

@@ -1,191 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package util
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/proxyplugin"
)
// Proxy environment constants control shared transport proxy behavior.
const (
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
EnvNoProxy = "LARK_CLI_NO_PROXY"
)
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
var proxyEnvKeys = []string{
"HTTPS_PROXY", "https_proxy",
"HTTP_PROXY", "http_proxy",
"ALL_PROXY", "all_proxy",
}
// DetectProxyEnv returns the first proxy-related environment variable that is set,
// or empty strings if none are configured.
func DetectProxyEnv() (key, value string) {
for _, k := range proxyEnvKeys {
if v := os.Getenv(k); v != "" {
return k, v
}
}
return "", ""
}
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
var proxyWarningOnce sync.Once
// proxyPluginStatus reports the configured proxy plugin address, the extra
// trusted CA path (if any), and whether proxy plugin mode is enabled. It is
// indirected through a package variable so tests can simulate plugin-enabled
// mode without the process-global proxyplugin.Load() sync.Once cache.
var proxyPluginStatus = func() (addr, caPath string, enabled bool) {
cfg, err := proxyplugin.Load()
if err != nil || !cfg.Enabled() {
return "", "", false
}
return cfg.Proxy, cfg.CAPath, true
}
// redactProxyURL masks userinfo (username:password) in a proxy URL.
// Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats.
func redactProxyURL(raw string) string {
// Try standard url.Parse first (works when scheme is present)
u, err := url.Parse(raw)
if err == nil && u.User != nil {
return u.Scheme + "://***@" + u.Host + u.RequestURI()
}
// Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080")
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]
}
return raw
}
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
// are redacted. Safe to call multiple times; only the first call prints.
//
// The warning is suppressed entirely when interactive is false — i.e. stdin is
// not a TTY, which is the case for agent / CI / piped invocations. Those callers
// frequently parse the CLI's stdout as JSON and merge streams with `2>&1`; a
// stray stderr warning then corrupts the parsed payload. Suppressing in the
// non-interactive case keeps machine-consumed output clean, while human
// interactive sessions still get the security notice. Passing interactive=false
// does not consume the once guard, so a later interactive call can still warn.
func WarnIfProxied(w io.Writer, interactive bool) {
if !interactive {
return
}
proxyWarningOnce.Do(func() {
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
// SharedTransport), so its warning and disable instructions take
// precedence. Emitting the env-proxy warning here would be misleading:
// it tells the user to set LARK_CLI_NO_PROXY=1, which does NOT disable
// the plugin proxy.
if addr, caPath, enabled := proxyPluginStatus(); enabled {
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n",
redactProxyURL(addr), envvars.CliProxyEnable, proxyplugin.Path())
if strings.TrimSpace(caPath) != "" {
// A custom CA means upstream TLS can be intercepted/inspected by
// the proxy (MITM). Surface it so the operator is aware traffic
// (including Bearer tokens) is decryptable on this host.
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n",
caPath)
}
return
}
if os.Getenv(EnvNoProxy) != "" {
return
}
key, val := DetectProxyEnv()
if key == "" {
return
}
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
key, redactProxyURL(val), EnvNoProxy)
})
}
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport,
// lazily built the first time LARK_CLI_NO_PROXY is observed set.
var noProxyTransport = sync.OnceValue(func() *http.Transport {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}
}
t := def.Clone()
t.Proxy = nil
return t
})
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
//
// By default it returns http.DefaultTransport — the stdlib-provided
// process-wide singleton — so every HTTP client in the process shares one
// TCP connection pool, TLS session cache, and HTTP/2 state. When
// LARK_CLI_NO_PROXY is set it returns a separate proxy-disabled singleton
// clone; LARK_CLI_NO_PROXY is checked on every call, but the clone is built
// at most once.
//
// The returned RoundTripper MUST NOT be mutated. Callers that need a
// customized transport should assert to *http.Transport and Clone() it.
// Using a shared base is required so persistConn readLoop/writeLoop
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
// (~90s) fires.
func SharedTransport() http.RoundTripper {
// proxy plugin mode overrides all other proxy behavior (env proxies and
// LARK_CLI_NO_PROXY), per operator intent.
if t, ok := proxyplugin.SharedTransport(); ok {
return t
}
if os.Getenv(EnvNoProxy) != "" {
return noProxyTransport()
}
return http.DefaultTransport
}
// FallbackTransport returns a shared *http.Transport singleton. It is a
// thin wrapper over SharedTransport retained so modules that were already
// on the leak-free singleton path (internal/auth, internal/cmdutil
// transport decorators) do not have to migrate. New code should prefer
// SharedTransport and treat the base as an http.RoundTripper.
//
// Fail-closed invariant: proxyplugin always expresses its blocked/fail-closed
// transport as a concrete *http.Transport (see proxyplugin.failClosedTransport),
// so the assertion below preserves the block. The noProxyTransport() fallback is
// therefore only reached when no proxy plugin is configured and some external
// code replaced http.DefaultTransport with a non-*http.Transport — a case with
// no fail-closed intent, where a proxy-disabled transport is acceptable.
func FallbackTransport() *http.Transport {
if t, ok := SharedTransport().(*http.Transport); ok {
return t
}
return noProxyTransport()
}
// NewHTTPClient returns an *http.Client whose Transport is the shared,
// proxy-plugin-aware base (see SharedTransport). Prefer this over a bare
// &http.Client{} for outbound requests: a bare client falls back to
// http.DefaultTransport and therefore silently bypasses proxy plugin mode
// (fixed proxy + trusted CA, or fail-closed), creating an audit blind spot.
//
// A zero timeout means no client-level timeout (callers relying on
// context deadlines pass 0).
func NewHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: SharedTransport(),
Timeout: timeout,
}
}

View File

@@ -25,6 +25,10 @@ var BaseAdvpermDisable = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
},
Tips: []string{
baseHighRiskYesTip,
"Disabling advanced permissions invalidates existing custom roles; confirm the target Base before passing --yes.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -25,6 +25,9 @@ var BaseAdvpermEnable = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
},
Tips: []string{
"Caller must be a Base admin; enable advanced permissions before creating or updating roles.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -24,6 +24,11 @@ var BaseBaseCopy = common.Shortcut{
{Name: "without-content", Type: "bool", Desc: "copy structure only"},
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
},
Tips: []string{
`Example: lark-cli base +base-copy --base-token <base_token> --name "Copy of Project Tracker"`,
"Use --without-content when the user wants only structure.",
"If copied as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.",
},
DryRun: dryRunBaseCopy,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseCopy(runtime)

View File

@@ -22,6 +22,10 @@ var BaseBaseCreate = common.Shortcut{
{Name: "folder-token", Desc: "folder token for destination"},
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
},
Tips: []string{
`Example: lark-cli base +base-create --name "Project Tracker" --time-zone Asia/Shanghai`,
"If created as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.",
},
DryRun: dryRunBaseCreate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseCreate(runtime)

View File

@@ -20,7 +20,12 @@ var BaseDataQuery = common.Shortcut{
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "dsl", Desc: "query JSON DSL (LiteQuery Protocol)", Required: 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{
"Use +data-query for server-side aggregation, grouping, filtering, sorting, and Top N queries.",
"Read lark-base-data-query-guide.md for common fewshots; use lark-base-data-query.md only when the full DSL reference is needed.",
"`dimensions` and `measures` cannot both be empty.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
var dsl map[string]interface{}

View File

@@ -515,7 +515,7 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
if !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if !strings.Contains(err.Error(), "lark-base skill") {
if !strings.Contains(err.Error(), "match the documented shape") {
t.Fatalf("err=%v", err)
}
if strings.Contains(err.Error(), "array") {

View File

@@ -25,6 +25,9 @@ var BaseFormCreate = common.Shortcut{
{Name: "name", Desc: "form name", Required: true},
{Name: "description", Desc: `form description (plain text or markdown link like [text](https://example.com))`},
},
Tips: []string{
"Record the returned form_id; form question create/list/update/delete commands need it.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms").

View File

@@ -22,6 +22,10 @@ var BaseFormDelete = common.Shortcut{
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},
Tips: []string{
"Use +form-list or +form-get first when the form target is ambiguous.",
baseHighRiskYesTip,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id").

View File

@@ -23,7 +23,7 @@ var BaseFormsList = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"},
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().

View File

@@ -25,6 +25,9 @@ var BaseFormQuestionsDelete = common.Shortcut{
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "question-ids", Desc: `JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`, Required: true},
},
Tips: []string{
baseHighRiskYesTip,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").

View File

@@ -25,6 +25,9 @@ var BaseFormQuestionsList = common.Shortcut{
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},
Tips: []string{
"Use returned question id values for +form-questions-update and +form-questions-delete.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").

View File

@@ -17,7 +17,10 @@ var BaseBaseGet = common.Shortcut{
Scopes: []string{"base:app:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true)},
DryRun: dryRunBaseGet,
Tips: []string{
"Use a real Base token; workspace tokens and wiki tokens are not accepted by this command.",
},
DryRun: dryRunBaseGet,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseGet(runtime)
},

View File

@@ -25,7 +25,12 @@ var BaseRoleCreate = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "json", Desc: `body JSON (AdvPermBaseRoleConfig), e.g. {"role_name":"Reviewer","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
{Name: "json", Desc: "role config JSON; read lark-base-role-guide.md and role-config.md before constructing permissions", Required: true},
},
Tips: []string{
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
"Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.",
"Create supports custom_role only.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -26,6 +26,12 @@ var BaseRoleDelete = common.Shortcut{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
},
Tips: []string{
baseHighRiskYesTip,
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
"Only custom roles can be deleted; system roles cannot be deleted.",
"Use +role-get first if the role target is ambiguous, then pass --yes to confirm deletion.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -27,6 +27,10 @@ var BaseRoleGet = common.Shortcut{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
},
Tips: []string{
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
"Use before +role-update to inspect the current full permission config.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -26,6 +26,10 @@ var BaseRoleList = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
},
Tips: []string{
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
"Returns role summaries; use +role-get for the full permission config.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -26,7 +26,13 @@ var BaseRoleUpdate = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
{Name: "json", Desc: `body JSON (delta AdvPermBaseRoleConfig), e.g. {"role_name":"New Name","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
{Name: "json", Desc: "delta role config JSON; read lark-base-role-guide.md and role-config.md before changing permissions", Required: true},
},
Tips: []string{
baseHighRiskYesTip,
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
"Update is a delta merge: only changed fields are updated, others remain unchanged.",
"Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
}
func jsonInputTip(flagName string) string {
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; for complex JSON/DSL, read the lark-base reference and match the documented shape", flagName)
}
func formatJSONError(flagName string, target string, err error) error {

View File

@@ -198,6 +198,25 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
}
}
func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) {
for _, shortcut := range Shortcuts() {
if shortcut.Risk != "high-risk-write" {
continue
}
parent := &cobra.Command{Use: "base"}
shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
flag := cmd.Flags().Lookup("yes")
if flag == nil {
t.Fatalf("%s missing --yes flag", shortcut.Command)
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
if !strings.Contains(tips, "pass --yes without asking again") {
t.Fatalf("%s tips missing agent guidance:\n%s", shortcut.Command, tips)
}
}
}
func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
@@ -251,20 +270,19 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
name: "record search",
shortcut: BaseRecordSearch,
wantHelp: []string{
"requires keyword/search_fields",
"optional select_fields/view_id/offset/limit",
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
"for keyword search only",
"output format: markdown (default) | json",
},
wantTips: []string{
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
`"select_fields":["Name","Status"]`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
"Happy path fields: keyword (string), search_fields",
"search_fields length 1-20",
"limit range 1-200 defaults to 10",
"view_id scopes search to records in that view",
"Default output is markdown",
"only for keyword search",
"lark-base record read SOP",
"inventing search JSON",
},
},
{
@@ -311,6 +329,401 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "dashboard list",
shortcut: BaseDashboardList,
wantTips: []string{
"Use returned dashboard_id values",
},
},
{
name: "dashboard get",
shortcut: BaseDashboardGet,
wantTips: []string{
"block-level details",
},
},
{
name: "dashboard create",
shortcut: BaseDashboardCreate,
wantTips: []string{
"Record the returned dashboard_id",
},
},
{
name: "dashboard update",
shortcut: BaseDashboardUpdate,
wantTips: []string{},
},
{
name: "dashboard delete",
shortcut: BaseDashboardDelete,
wantTips: []string{
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
"also deletes its blocks",
"pass --yes",
},
},
{
name: "dashboard arrange",
shortcut: BaseDashboardArrange,
wantTips: []string{
"not deterministic or position-specific",
},
},
{
name: "dashboard block list",
shortcut: BaseDashboardBlockList,
wantTips: []string{
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
"Use returned block_id and type values",
},
},
{
name: "dashboard block get",
shortcut: BaseDashboardBlockGet,
wantTips: []string{
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
"metadata such as name, type, layout, and data_config",
"computed chart result",
},
},
{
name: "dashboard block get data",
shortcut: BaseDashboardBlockGetData,
wantTips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
"does not need --dashboard-id",
"computed chart protocol JSON",
},
},
{
name: "dashboard block create",
shortcut: BaseDashboardBlockCreate,
wantTips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
`--type text --data-config '{"text":"# Sales Dashboard"}'`,
"+table-list and +field-list",
"not table_id or field_id",
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"sequentially",
},
},
{
name: "dashboard block update",
shortcut: BaseDashboardBlockUpdate,
wantTips: []string{
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
`--data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"Block type cannot be changed",
"top-level keys",
},
},
{
name: "dashboard block delete",
shortcut: BaseDashboardBlockDelete,
wantTips: []string{
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
"pass --yes",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseWorkflowHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "workflow list",
shortcut: BaseWorkflowList,
wantTips: []string{
"workflow_id values with wkf prefix",
"auto-paginates",
},
},
{
name: "workflow get",
shortcut: BaseWorkflowGet,
wantTips: []string{
"workflow-id must start with wkf",
"steps may be an empty array",
"Use +workflow-get before +workflow-update",
"lark-base-workflow-schema.md",
},
},
{
name: "workflow create",
shortcut: BaseWorkflowCreate,
wantTips: []string{
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
"client_token is required",
"New workflows are created disabled",
"+table-list and +field-list",
"Step ids must be unique",
"lark-base-workflow-guide.md as the entry guide",
"lark-base-workflow-schema.md as the steps JSON SSOT",
"do not invent steps[].type/data/next/children from natural language",
},
},
{
name: "workflow update",
shortcut: BaseWorkflowUpdate,
wantTips: []string{
"lark-cli base +workflow-update --base-token <base_token> --workflow-id <workflow_id> --json @workflow.json",
"PUT uses full replacement semantics",
"Use +workflow-get first",
"keep title/status/steps fields",
"workflow-id must start with wkf",
"Updating does not enable or disable",
"do not invent steps[].type/data/next/children from natural language",
},
},
{
name: "workflow enable",
shortcut: BaseWorkflowEnable,
wantTips: []string{
"workflow-id must start with wkf",
"does not modify steps",
"New workflows are created disabled",
},
},
{
name: "workflow disable",
shortcut: BaseWorkflowDisable,
wantTips: []string{
"workflow-id must start with wkf",
"does not delete the workflow or its steps",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
}{
{
name: "table create fields",
shortcut: BaseTableCreate,
wantHelp: []string{
`field JSON array for create, e.g. [{"name":"Title","type":"text"}`,
},
},
{
name: "view set filter",
shortcut: BaseViewSetFilter,
wantHelp: []string{
`filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`,
},
},
{
name: "view set sort",
shortcut: BaseViewSetSort,
wantHelp: []string{
`sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}`,
`use {"sort_config":[]} to clear`,
},
},
{
name: "view set group",
shortcut: BaseViewSetGroup,
wantHelp: []string{
`group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}`,
},
},
{
name: "view set card",
shortcut: BaseViewSetCard,
wantHelp: []string{
`card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`,
},
},
{
name: "view set timebar",
shortcut: BaseViewSetTimebar,
wantHelp: []string{
`timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`,
},
},
{
name: "view set visible fields",
shortcut: BaseViewSetVisibleFields,
wantHelp: []string{
`visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`,
},
},
{
name: "form question delete",
shortcut: BaseFormQuestionsDelete,
wantHelp: []string{
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
},
},
{
name: "record search json",
shortcut: BaseRecordSearch,
wantHelp: []string{
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
},
},
{
name: "record upsert json",
shortcut: BaseRecordUpsert,
wantHelp: []string{
`record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`,
},
},
{
name: "record batch create json",
shortcut: BaseRecordBatchCreate,
wantHelp: []string{
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
},
},
{
name: "record batch update json",
shortcut: BaseRecordBatchUpdate,
wantHelp: []string{
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
for _, want := range tt.wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
})
}
}
func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "record upsert",
shortcut: BaseRecordUpsert,
wantTips: []string{
"Happy path JSON is a top-level field map",
"Without --record-id this creates a record",
"does not auto-upsert by business key",
"use +field-list to confirm real writable fields",
"do not write system fields, formula, lookup, or attachment fields",
"CellValue happy path: text/phone/url",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
"datetime -> \"2026-03-24 10:00:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
`location uses {"lng":116.397428,"lat":39.90923}`,
"Do not guess user/chat/linked-record IDs or location coordinates",
"lark-base-cell-value.md",
"do not invent values for fields not covered by the happy path",
},
},
{
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
"Happy path fields: fields is the column order",
"rows is an array of row arrays",
"may use null for empty cells",
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
"lark-base-cell-value.md",
"do not invent values for fields not covered by the happy path",
},
},
{
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
wantTips: []string{
"Happy path fields: record_id_list is the target record IDs",
"patch is a field map applied unchanged to every target record",
"Do not use +record-batch-update for per-row different values",
"use +field-list to confirm real writable fields",
"Batch update supports max 200 records per call",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
"lark-base-cell-value.md",
"do not invent values for fields not covered by the happy path",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
@@ -328,7 +741,7 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
"full field-definition PUT semantics",
"Read the current field first with +field-get",

View File

@@ -22,6 +22,9 @@ var BaseDashboardArrange = common.Shortcut{
dashboardIDFlag(true),
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
},
Tips: []string{
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard.",
},
DryRun: dryRunDashboardArrange,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeDashboardArrange(runtime)

View File

@@ -25,10 +25,19 @@ var BaseDashboardBlockCreate = common.Shortcut{
dashboardIDFlag(true),
{Name: "name", Desc: "block name", Required: true},
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
{Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"},
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
},
Tips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Dashboard Note" --type text --data-config '{"text":"# Sales Dashboard"}'`,
"Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.",
"data_config uses table and field names, not table_id or field_id.",
"Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
"Record the returned block_id; block update/delete/get-data commands need it.",
"Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
if runtime.Bool("no-validate") {

View File

@@ -22,6 +22,10 @@ var BaseDashboardBlockDelete = common.Shortcut{
dashboardIDFlag(true),
blockIDFlag(true),
},
Tips: []string{
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
baseHighRiskYesTip,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id").

View File

@@ -24,6 +24,11 @@ var BaseDashboardBlockGet = common.Shortcut{
blockIDFlag(true),
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
},
Tips: []string{
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
"Use this command for block metadata such as name, type, layout, and data_config.",
"Use +dashboard-block-get-data when you need the computed chart result instead of metadata.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{}
if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" {

View File

@@ -23,6 +23,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
},
Tips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
"This command does not need --dashboard-id.",
"Use +dashboard-block-get first when you need block metadata like name, type, or data_config.",
"This command returns computed chart protocol JSON directly, not wrapped block metadata.",
"Text blocks do not have computed chart data; this shortcut is for chart/statistics blocks.",

View File

@@ -21,9 +21,13 @@ var BaseDashboardBlockList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
dashboardIDFlag(true),
{Name: "page-size", Desc: "page size (max 100)"},
{Name: "page-size", Desc: "page size, default 20, max 100"},
{Name: "page-token", Desc: "pagination token"},
},
Tips: []string{
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
"Use returned block_id and type values for +dashboard-block-get/update/delete/get-data.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{}
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {

View File

@@ -24,10 +24,18 @@ var BaseDashboardBlockUpdate = common.Shortcut{
dashboardIDFlag(true),
blockIDFlag(true),
{Name: "name", Desc: "new block name"},
{Name: "data-config", Desc: "data config JSON. For chart types: table_name, series|count_all, group_by, filter. For text type: text (markdown supported). See dashboard-block-data-config.md for details."},
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
},
Tips: []string{
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
"Read dashboard-block-data-config.md as the SSOT for data_config templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
"Use +dashboard-block-get first to inspect the current data_config before replacing nested values.",
"Block type cannot be changed; delete and recreate the block to change chart type.",
"data_config update merges top-level keys, but each provided key is replaced as a whole.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
if runtime.Bool("no-validate") {

View File

@@ -20,7 +20,10 @@ var BaseDashboardCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "name", Desc: "dashboard name", Required: true},
{Name: "theme-style", Desc: "theme style"},
{Name: "theme-style", Desc: "theme style, defaults to platform default when omitted"},
},
Tips: []string{
"Record the returned dashboard_id; dashboard block create/get/update/delete/arrange commands need it.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{}

View File

@@ -21,6 +21,11 @@ var BaseDashboardDelete = common.Shortcut{
baseTokenFlag(true),
dashboardIDFlag(true),
},
Tips: []string{
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
"Deleting a dashboard also deletes its blocks and cannot be recovered.",
baseHighRiskYesTip,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").

View File

@@ -21,6 +21,9 @@ var BaseDashboardGet = common.Shortcut{
baseTokenFlag(true),
dashboardIDFlag(true),
},
Tips: []string{
"Use +dashboard-block-list or +dashboard-block-get when you need block-level details.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").

View File

@@ -20,9 +20,12 @@ var BaseDashboardList = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "page-size", Desc: "page size (max 100)"},
{Name: "page-size", Desc: "page size, max 100"},
{Name: "page-token", Desc: "pagination token"},
},
Tips: []string{
"Use returned dashboard_id values for +dashboard-get, +dashboard-block-list, and +dashboard-block-create.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{}
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {

View File

@@ -21,7 +21,7 @@ var BaseDashboardUpdate = common.Shortcut{
baseTokenFlag(true),
dashboardIDFlag(true),
{Name: "name", Desc: "new dashboard name"},
{Name: "theme-style", Desc: "theme style"},
{Name: "theme-style", Desc: "theme style, leave empty to keep current theme"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{}

View File

@@ -23,7 +23,8 @@ var BaseFieldCreate = common.Shortcut{
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
},
Tips: []string{
`Example: --json '{"name":"Status","type":"text"}'`,
`Example text: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"text"}'`,
`Example select: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -17,7 +17,11 @@ var BaseFieldDelete = common.Shortcut{
Scopes: []string{"base:field:delete"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
DryRun: dryRunFieldDelete,
Tips: []string{
baseHighRiskYesTip,
`Example: lark-cli base +field-delete --base-token <base_token> --table-id <table_id> --field-id "Status" --yes`,
},
DryRun: dryRunFieldDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFieldDelete(runtime)
},

View File

@@ -17,7 +17,12 @@ var BaseFieldGet = common.Shortcut{
Scopes: []string{"base:field:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
DryRun: dryRunFieldGet,
Tips: []string{
`Example: lark-cli base +field-get --base-token <base_token> --table-id <table_id> --field-id "Status"`,
"field-id accepts a field ID (fld...) or the field name from the current table.",
"Returns full field configuration; use it as the baseline before +field-update.",
},
DryRun: dryRunFieldGet,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFieldGet(runtime)
},

View File

@@ -20,7 +20,7 @@ var BaseFieldList = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
},
DryRun: dryRunFieldList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -22,7 +22,11 @@ var BaseFieldSearchOptions = common.Shortcut{
fieldRefFlag(true),
{Name: "keyword", Desc: "keyword for option query"},
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size"},
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"},
},
Tips: []string{
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
"Use only for fields with options, such as select or multi-select fields.",
},
DryRun: dryRunFieldSearchOptions,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -24,8 +24,9 @@ var BaseFieldUpdate = common.Shortcut{
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
},
Tips: []string{
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
baseHighRiskYesTip,
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
"Formula and lookup updates require reading the corresponding guide first.",

View File

@@ -38,7 +38,7 @@ func TestParseHelpers(t *testing.T) {
if err != nil || obj["name"] != "demo" {
t.Fatalf("obj=%v err=%v", obj, err)
}
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "match the documented shape") || strings.Contains(err.Error(), "array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
@@ -66,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "complex JSON/DSL") {
t.Fatalf("err=%v", err)
}
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
@@ -334,11 +334,11 @@ func TestJSONInputHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "complex JSON/DSL") {
t.Fatalf("syntaxErr=%v", syntaxErr)
}
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "complex JSON/DSL") {
t.Fatalf("typeErr=%v", typeErr)
}
}

View File

@@ -0,0 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
const baseHighRiskYesTip = "This is a high-risk write command. If the user explicitly requested it and the target is unambiguous, pass --yes without asking again."

View File

@@ -19,13 +19,14 @@ var BaseRecordBatchCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "batch create JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
"Agent hint: use lark-base-cell-value.md as the source of truth for each CellValue.",
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
},
Tips: append([]string{
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 rows per call.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},

View File

@@ -19,13 +19,14 @@ var BaseRecordBatchUpdate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "batch update JSON object", Required: true},
},
Tips: []string{
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
"Agent hint: use lark-base-cell-value.md as the source of truth for each patch CellValue.",
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
},
Tips: append([]string{
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},

View File

@@ -22,6 +22,10 @@ var BaseRecordDelete = common.Shortcut{
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
},
Tips: []string{
baseHighRiskYesTip,
`Example: lark-cli base +record-delete --base-token <base_token> --table-id <table_id> --record-id <record_id_1> --record-id <record_id_2> --yes`,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordSelection(runtime)
},

View File

@@ -21,7 +21,11 @@ var BaseRecordHistoryList = common.Shortcut{
tableRefFlag(true),
recordRefFlag(true),
{Name: "max-version", Type: "int", Desc: "max version for next page"},
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size"},
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size, max 50"},
},
Tips: []string{
`Example: lark-cli base +record-history-list --base-token <base_token> --table-id <table_id> --record-id <record_id>`,
"This reads one record's history only; it is not a table-wide audit scan.",
},
DryRun: dryRunRecordHistoryList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -15,6 +15,13 @@ import (
const maxRecordSelectionCount = 200
const maxBatchGetSelectFieldCount = 100
var recordCellValueHappyPathTips = []string{
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",
}
type recordSelection struct {
recordIDs []string
selectFields []string

View File

@@ -20,17 +20,15 @@ var BaseRecordSearch = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true},
{Name: "json", Desc: `record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}; for keyword search only`, Required: true},
recordReadFormatFlag(),
},
Tips: []string{
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
`Happy path fields: keyword (string), search_fields (1-20 field names/ids), select_fields (optional projection, <=50), view_id (optional), offset (default 0), limit (default 10, range 1-200).`,
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.",
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
"Use +record-search only for keyword search; for structured conditions, sorting, Top/Bottom N, or global conclusions, follow the lark-base record read SOP instead of inventing search JSON.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {

View File

@@ -22,8 +22,9 @@ var BaseRecordShareLinkCreate = common.Shortcut{
{Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true},
},
Tips: []string{
`Single record: --base-token xxx --table-id tblxxx --record-ids recxxx`,
`Multiple records: --base-token xxx --table-id tblxxx --record-ids rec001,rec002,rec003`,
`Example: lark-cli base +record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>`,
"Max 100 record IDs per call; duplicate IDs are ignored.",
"Output record_share_links maps record_id to URL; records without permission or missing records may be absent.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordShareBatch(runtime)

View File

@@ -117,6 +117,7 @@ var BaseRecordRemoveAttachment = common.Shortcut{
{Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true},
},
Tips: []string{
baseHighRiskYesTip,
`Example: lark-cli base +record-remove-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file-token <file_token> --yes`,
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
`This is a high-risk write command and requires --yes.`,

View File

@@ -20,12 +20,14 @@ var BaseRecordUpsert = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
recordRefFlag(false),
{Name: "json", Desc: "record JSON object: Map<FieldNameOrID, CellValue>", Required: true},
},
Tips: []string{
`Example: --json '{"Name":"Alice"}'`,
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
{Name: "json", Desc: `record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`, Required: true},
},
Tips: append([]string{
"Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.",
"Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Use the record-upsert guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},

View File

@@ -20,7 +20,11 @@ var BaseTableCreate = common.Shortcut{
baseTokenFlag(true),
{Name: "name", Desc: "table name", Required: true},
{Name: "view", Desc: "view JSON object/array for create"},
{Name: "fields", Desc: "field JSON array for create"},
{Name: "fields", Desc: `field JSON array for create, e.g. [{"name":"Title","type":"text"},{"name":"Status","type":"select","options":[{"name":"Todo"},{"name":"Done"}]}]`},
},
Tips: []string{
"Before using --fields, read lark-base-field-json.md or rely on the same field JSON shape used by +field-create; do not invent field properties.",
"The first --fields item replaces the default field.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateTableCreate(runtime)

View File

@@ -17,7 +17,12 @@ var BaseTableDelete = common.Shortcut{
Scopes: []string{"base:table:delete"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
DryRun: dryRunTableDelete,
Tips: []string{
`Example: lark-cli base +table-delete --base-token <base_token> --table-id "Old Tasks" --yes`,
"table-id accepts a table ID (tbl...) or the table name in the current Base.",
baseHighRiskYesTip,
},
DryRun: dryRunTableDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeTableDelete(runtime)
},

View File

@@ -17,7 +17,11 @@ var BaseTableGet = common.Shortcut{
Scopes: []string{"base:table:read", "base:field:read", "base:view:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
DryRun: dryRunTableGet,
Tips: []string{
`Example: lark-cli base +table-get --base-token <base_token> --table-id "Tasks"`,
"table-id accepts a table ID (tbl...) or the table name in the current Base.",
},
DryRun: dryRunTableGet,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeTableGet(runtime)
},

View File

@@ -19,7 +19,7 @@ var BaseTableList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "50", Desc: "pagination limit"},
{Name: "limit", Type: "int", Default: "50", Desc: "pagination size, range 1-100"},
},
DryRun: dryRunTableList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -19,11 +19,13 @@ var BaseViewCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "view JSON object/array", Required: true},
{Name: "json", Desc: "view JSON object/array; type defaults to grid; type range: grid, kanban, gallery, calendar, gantt", Required: true},
},
Tips: []string{
`Example: --json '{"name":"Main","type":"grid"}'`,
"Agent hint: use the lark-base skill's view-create guide for usage and limits.",
`Example: lark-cli base +view-create --base-token <base_token> --table-id <table_id> --json '{"name":"Main","type":"grid"}'`,
`Minimal: --json '{"name":"Main"}' creates a grid view.`,
"Do not pass form as a view type; form views are managed through form commands.",
`Use +view-set-visible-fields after creation when the user needs a specific field order or visibility.`,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewCreate(runtime)

View File

@@ -17,7 +17,11 @@ var BaseViewDelete = common.Shortcut{
Scopes: []string{"base:view:write_only"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)},
DryRun: dryRunViewDelete,
Tips: []string{
baseHighRiskYesTip,
`Example: lark-cli base +view-delete --base-token <base_token> --table-id <table_id> --view-id "Old View" --yes`,
},
DryRun: dryRunViewDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeViewDelete(runtime)
},

View File

@@ -20,7 +20,7 @@ var BaseViewList = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
},
DryRun: dryRunViewList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -20,11 +20,12 @@ var BaseViewSetCard = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "card JSON object", Required: true},
{Name: "json", Desc: `card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`, Required: true},
},
Tips: []string{
`Example: --json '{"cover_field":"fldCover"}'`,
"Agent hint: use the lark-base skill's view-set-card guide for usage and limits.",
"Supported view types: gallery, kanban.",
"cover_field should be an attachment field id/name, or null to clear.",
"Use +view-get-card first when updating an existing card view configuration.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)

View File

@@ -20,10 +20,9 @@ var BaseViewSetFilter = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "filter JSON object", Required: true},
{Name: "json", Desc: `filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`, Required: true},
},
Tips: []string{
`Example: --json '{"logic":"and","conditions":[["fldStatus","==","Todo"]]}'`,
"Agent hint: use the lark-base skill's view-set-filter guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -20,11 +20,13 @@ var BaseViewSetGroup = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "group JSON object", Required: true},
{Name: "json", Desc: `group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}; use {"group_config":[]} to clear`, Required: true},
},
Tips: []string{
`Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`,
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
"Supported view types: grid, kanban, gantt.",
"Use a JSON object, not a bare array; grouping fields must be supported by the current view.",
"group_config supports max 3 group items.",
"Use +view-get-group first when modifying an existing grouping configuration.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)

View File

@@ -20,11 +20,13 @@ var BaseViewSetSort = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "sort_config JSON object", Required: true},
{Name: "json", Desc: `sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}; use {"sort_config":[]} to clear; max 10 items`, Required: true},
},
Tips: []string{
`Example: --json '{"sort_config":[{"field":"fldPriority","desc":true}]}'`,
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
"Supported view types: grid, kanban, gallery, gantt.",
"Use a JSON object, not a bare array; sorting fields must be supported by the current view.",
"sort_config supports max 10 sort items.",
"Use +view-get-sort first when modifying an existing sort configuration.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)

View File

@@ -20,11 +20,12 @@ var BaseViewSetTimebar = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: "timebar JSON object", Required: true},
{Name: "json", Desc: `timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`, Required: true},
},
Tips: []string{
`Example: --json '{"start_time":"fldStart","end_time":"fldEnd","title":"fldTitle"}'`,
"Agent hint: use the lark-base skill's view-set-timebar guide for usage and limits.",
"Supported view types: calendar, gantt.",
"start_time, end_time, and title are required; use date/time fields for start_time and end_time.",
"Use +view-get-timebar first when modifying an existing timebar configuration.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)

View File

@@ -20,11 +20,12 @@ var BaseViewSetVisibleFields = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
viewRefFlag(true),
{Name: "json", Desc: `visible fields JSON object with "visible_fields"`, Required: true},
{Name: "json", Desc: `visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`, Required: true},
},
Tips: []string{
`Example: --json '{"visible_fields":["fldXXX"]}'`,
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
"Supported view types: grid, kanban, gallery, calendar, gantt.",
"Use a JSON object, not a bare array; primary field may be forced to the first position by the API.",
"visible_fields controls both visibility and order; include every field that should remain visible.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)

View File

@@ -19,7 +19,15 @@ var BaseWorkflowCreate = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}`, Required: true},
{Name: "json", Desc: "workflow body JSON; read lark-base-workflow-guide.md and lark-base-workflow-schema.md before constructing steps", Required: true},
},
Tips: []string{
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
"client_token is required and should be unique per create request.",
"New workflows are created disabled; call +workflow-enable after creation when the user wants it active.",
"Before constructing steps, use +table-list and +field-list to confirm real table and field names.",
"Step ids must be unique, and every next/children link must reference an existing step id.",
"Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -21,6 +21,10 @@ var BaseWorkflowDisable = common.Shortcut{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
},
Tips: []string{
"workflow-id must start with wkf; do not pass a tbl table ID from the same URL.",
"Disable only changes workflow state; it does not delete the workflow or its steps.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -21,6 +21,11 @@ var BaseWorkflowEnable = common.Shortcut{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
},
Tips: []string{
"workflow-id must start with wkf; do not pass a tbl table ID from the same URL.",
"Enable only changes workflow state; it does not modify steps.",
"New workflows are created disabled; enable after creation only when the user wants it active.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")

View File

@@ -20,7 +20,13 @@ var BaseWorkflowGet = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
{Name: "user-id-type", Desc: "user ID type for creator/updater fields", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "user-id-type", Desc: "user ID type for creator/updater fields, default open_id", Enum: []string{"open_id", "union_id", "user_id"}},
},
Tips: []string{
"workflow-id must start with wkf; use +workflow-list if the ID is unknown.",
"steps may be an empty array; that is valid for an unconfigured workflow.",
"Use +workflow-get before +workflow-update, then edit the returned definition and keep fields you do not intend to change.",
"Read lark-base-workflow-schema.md when interpreting or reusing returned steps.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -20,7 +20,11 @@ var BaseWorkflowList = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "status", Desc: "filter by status", Enum: []string{"enabled", "disabled"}},
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"},
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"},
},
Tips: []string{
"Returns workflow_id values with wkf prefix; pass those IDs to +workflow-get/enable/disable/update.",
"This shortcut auto-paginates and returns all matched workflows.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -20,7 +20,16 @@ var BaseWorkflowUpdate = common.Shortcut{
Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}`, Required: true},
{Name: "json", Desc: "workflow body JSON; read lark-base-workflow-guide.md and lark-base-workflow-schema.md before replacing steps", Required: true},
},
Tips: []string{
"lark-cli base +workflow-update --base-token <base_token> --workflow-id <workflow_id> --json @workflow.json",
"PUT uses full replacement semantics; omitting steps clears the existing workflow steps.",
"Use +workflow-get first, then edit the returned definition and keep title/status/steps fields you do not intend to change.",
"workflow-id must start with wkf; do not pass a tbl table ID.",
"Step ids must be unique, and every next/children link must reference an existing step id.",
"Updating does not enable or disable a workflow; call +workflow-enable or +workflow-disable separately.",
"Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -210,12 +210,6 @@ func (ctx *RuntimeContext) Int(name string) int {
return v
}
// Int64 returns an int64 flag value.
func (ctx *RuntimeContext) Int64(name string) int64 {
v, _ := ctx.Cmd.Flags().GetInt64(name)
return v
}
// Float64 returns a float64 flag value (non-integer numbers).
func (ctx *RuntimeContext) Float64(name string) float64 {
v, _ := ctx.Cmd.Flags().GetFloat64(name)
@@ -771,19 +765,26 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
return runShortcut(cmd, f, &shortcut, botOnly)
},
}
if shortcut.PrintFlagSchema != nil {
// --print-schema is pure local introspection; relax cobra's
// required-flag gate so callers don't need to fill in unrelated
// flags just to ask for a schema. ValidateRequiredFlags runs
// after PreRunE in cobra, so clearing the annotation here is the
// supported way to opt out.
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
onInvoke := shortcut.OnInvoke
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
// surface even when the call later fails on a missing required flag.
// - --print-schema: pure local introspection; relax the required-flag
// gate so callers don't fill in unrelated flags just to ask for a
// schema (clearing the annotation here is the supported opt-out).
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
if want, _ := c.Flags().GetBool("print-schema"); !want {
return nil
if onInvoke != nil {
onInvoke()
}
if relaxRequiredForSchema {
if want, _ := c.Flags().GetBool("print-schema"); want {
c.Flags().VisitAll(func(fl *pflag.Flag) {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
})
}
}
c.Flags().VisitAll(func(fl *pflag.Flag) {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
})
return nil
}
}
@@ -808,6 +809,13 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
flagName, _ := cmd.Flags().GetString("flag-name")
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
if err != nil {
// PrintFlagSchema implementations return bare errors; wrap as a
// structured ExitError so --print-schema (an agent-facing
// introspection path) yields a parseable envelope, not a plain
// string.
if _, ok := err.(*output.ExitError); !ok {
err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error())
}
return err
}
if len(out) == 0 {
@@ -917,9 +925,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
}
rctx.larkSDK = sdk
if s.HasFormat {
rctx.Format = rctx.Str("format")
}
rctx.Format = rctx.Str("format")
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
return rctx, nil
}
@@ -1075,10 +1081,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
var d int
fmt.Sscanf(fl.Default, "%d", &d)
cmd.Flags().Int(fl.Name, d, desc)
case "int64":
var d int64
fmt.Sscanf(fl.Default, "%d", &d)
cmd.Flags().Int64(fl.Name, d, desc)
case "float64":
var d float64
fmt.Sscanf(fl.Default, "%g", &d)
@@ -1105,8 +1107,11 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
}
cmd.Flags().Bool("dry-run", false, "print request without executing")
if s.HasFormat {
if cmd.Flags().Lookup("format") == nil {
cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
}
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
@@ -1117,9 +1122,4 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
}
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
if s.HasFormat {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// TestShortcutMount_FormatFlagAlwaysRegistered verifies that --format is
// injected for every shortcut regardless of the HasFormat field value.
func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "im",
Command: "+message-send",
Description: "send message",
HasFormat: false, // explicitly false — format must still be registered
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+message-send"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
flag := cmd.Flags().Lookup("format")
if flag == nil {
t.Fatal("--format flag not registered; expected it to be injected even when HasFormat is false")
}
if flag.DefValue != "json" {
t.Errorf("--format default = %q, want %q", flag.DefValue, "json")
}
}

View File

@@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "int64" | "float64" | "string_array" | "string_slice"
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime
@@ -49,7 +49,7 @@ type Shortcut struct {
// Declarative fields (new framework).
AuthTypes []string // supported identities: "user", "bot" (default: ["user"])
Flags []Flag // flag definitions; --dry-run is auto-injected
HasFormat bool // auto-inject --format flag (json|pretty|table|ndjson|csv)
HasFormat bool // Deprecated: --format is now always injected; this field has no effect.
Tips []string // optional tips shown in --help output
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
@@ -58,6 +58,14 @@ type Shortcut struct {
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
// cobra validates required flags — so its side effect fires even when the
// call later fails on a missing required flag (which short-circuits before
// Validate/Execute). The backward-compat aliases use it to record a
// deprecation notice that must surface regardless of whether the call
// validates. Fire-and-forget: no args, no return (e.g. deprecation.SetPending).
OnInvoke func()
// PrintFlagSchema, when non-nil, opts this shortcut into the
// `--print-schema --flag-name <name>` runtime introspection contract.
// The framework auto-injects those two system flags and short-circuits

View File

@@ -6,7 +6,9 @@ package shortcuts
import (
"context"
"fmt"
"os"
"slices"
"strings"
"github.com/larksuite/cli/shortcuts/okr"
"github.com/spf13/cobra"
@@ -14,6 +16,7 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/apps"
@@ -29,6 +32,7 @@ import (
"github.com/larksuite/cli/shortcuts/markdown"
"github.com/larksuite/cli/shortcuts/minutes"
"github.com/larksuite/cli/shortcuts/sheets"
sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward"
"github.com/larksuite/cli/shortcuts/slides"
"github.com/larksuite/cli/shortcuts/task"
"github.com/larksuite/cli/shortcuts/vc"
@@ -53,35 +57,104 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
return slices.Contains(allowed, brand)
}
// allShortcuts aggregates shortcuts from all domain packages.
var allShortcuts []common.Shortcut
// baseShortcuts aggregates every shortcut except sheets. Sheets are kept out of
// the global init path because their registration currently pulls in large
// embedded flag metadata; we only mount them when the current invocation is
// actually targeting sheets (or when completion/help needs the full tree).
var baseShortcuts []common.Shortcut
func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
allShortcuts = append(allShortcuts, im.Shortcuts()...)
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
allShortcuts = append(allShortcuts, base.Shortcuts()...)
allShortcuts = append(allShortcuts, event.Shortcuts()...)
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
baseShortcuts = append(baseShortcuts, apps.Shortcuts()...)
baseShortcuts = append(baseShortcuts, calendar.Shortcuts()...)
baseShortcuts = append(baseShortcuts, doc.Shortcuts()...)
baseShortcuts = append(baseShortcuts, drive.Shortcuts()...)
baseShortcuts = append(baseShortcuts, im.Shortcuts()...)
baseShortcuts = append(baseShortcuts, contact_shortcuts.Shortcuts()...)
baseShortcuts = append(baseShortcuts, base.Shortcuts()...)
baseShortcuts = append(baseShortcuts, event.Shortcuts()...)
baseShortcuts = append(baseShortcuts, mail.Shortcuts()...)
baseShortcuts = append(baseShortcuts, markdown.Shortcuts()...)
baseShortcuts = append(baseShortcuts, slides.Shortcuts()...)
baseShortcuts = append(baseShortcuts, minutes.Shortcuts()...)
baseShortcuts = append(baseShortcuts, task.Shortcuts()...)
baseShortcuts = append(baseShortcuts, vc.Shortcuts()...)
baseShortcuts = append(baseShortcuts, whiteboard.Shortcuts()...)
baseShortcuts = append(baseShortcuts, wiki.Shortcuts()...)
baseShortcuts = append(baseShortcuts, okr.Shortcuts()...)
}
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
//
//go:noinline
func AllShortcuts() []common.Shortcut {
return append([]common.Shortcut(nil), allShortcuts...)
return append([]common.Shortcut(nil), allShortcuts(true)...)
}
func allShortcuts(includeSheets bool) []common.Shortcut {
out := append([]common.Shortcut(nil), baseShortcuts...)
if includeSheets {
out = append(out, sheets.Shortcuts()...)
// Backward-compatible sheets shortcuts (pre-refactor command names),
// kept under shortcuts/sheets/backward so external callers relying on the
// old `+create`, `+read`, `+write`, ... commands keep working alongside the
// refactored ones. Command names are disjoint from sheets.Shortcuts().
out = append(out, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...)
}
return out
}
func shouldIncludeSheetsShortcuts(_ *cmdutil.Factory) bool {
args := os.Args[1:]
if len(args) == 0 {
return true
}
rootNames := map[string]struct{}{
"api": {},
"auth": {},
"completion": {},
"config": {},
"doctor": {},
"event": {},
"help": {},
"profile": {},
"schema": {},
"update": {},
}
for _, sc := range baseShortcuts {
rootNames[sc.Service] = struct{}{}
}
rootNames["sheets"] = struct{}{}
rootNames["__complete"] = struct{}{}
rootNames["__completeNoDesc"] = struct{}{}
for i, arg := range args {
if strings.HasPrefix(arg, "-") {
continue
}
if arg == "help" {
for _, next := range args[i+1:] {
if strings.HasPrefix(next, "-") {
continue
}
return next == "sheets"
}
return true
}
if _, ok := rootNames[arg]; !ok {
continue
}
switch arg {
case "sheets", "completion", "__complete", "__completeNoDesc":
return true
default:
return false
}
}
// Unknown argv shape: keep the pre-change conservative behavior and mount
// the full tree rather than accidentally hiding a command.
return true
}
// RegisterShortcuts registers all +shortcut commands on the program.
@@ -100,7 +173,7 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
// Group by service
byService := make(map[string][]common.Shortcut)
for _, s := range allShortcuts {
for _, s := range allShortcuts(shouldIncludeSheetsShortcuts(f)) {
byService[s.Service] = append(byService[s.Service], s)
}
@@ -146,6 +219,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
if service == "mail" {
mail.InstallOnMail(svc)
}
if service == "sheets" {
applySheetsCompatGroups(svc)
}
if !IsShortcutServiceAvailable(service, brand) {
installBrandRestrictionGuard(svc, service, brand)
@@ -189,3 +265,153 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
// --help bypasses RunE, so surface the restriction in Long too.
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
}
// Sheets backward-compatibility help grouping.
//
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
// users whose lark-sheets skill predates the refactor keep working even after
// upgrading only the binary. In `sheets --help` those aliases would otherwise
// sort alphabetically into the same flat list as the current commands,
// indistinguishable from them. applySheetsCompatGroups splits them into a
// dedicated cobra group whose heading tells the user to update their skill, and
// appends a "(→ +new-command)" pointer to each alias so the migration target is
// obvious. Pure presentation — the aliases stay fully executable.
const (
sheetsCurrentGroupID = "sheets-current"
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
// `sheets --help` grouping and the generic unknown-subcommand path
// (cmd/root.go) classify these aliases the same way.
sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID
)
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
// absent from this map still land in the deprecated group, just without a
// pointer, so a missing entry degrades gracefully rather than misgrouping.
var sheetsAliasReplacement = map[string]string{
// spreadsheet / sheet management
"+create": "+workbook-create",
"+info": "+workbook-info",
"+export": "+workbook-export",
"+create-sheet": "+sheet-create",
"+copy-sheet": "+sheet-copy",
"+delete-sheet": "+sheet-delete",
"+update-sheet": "+sheet-rename / +sheet-move / …",
// cell data
"+read": "+cells-get",
"+write": "+cells-set",
"+append": "+cells-set",
"+find": "+cells-search",
"+replace": "+cells-replace",
// cell style / merge / image
"+set-style": "+cells-set-style",
"+batch-set-style": "+cells-batch-set-style",
"+merge-cells": "+cells-merge",
"+unmerge-cells": "+cells-unmerge",
"+write-image": "+cells-set-image",
// row / column dimensions
"+add-dimension": "+dim-insert",
"+insert-dimension": "+dim-insert",
"+update-dimension": "+rows-resize / +dim-hide / …",
"+move-dimension": "+dim-move",
"+delete-dimension": "+dim-delete",
// filter views (conditions folded into the view flags)
"+create-filter-view": "+filter-view-create",
"+update-filter-view": "+filter-view-update",
"+list-filter-views": "+filter-view-list",
"+get-filter-view": "+filter-view-list",
"+delete-filter-view": "+filter-view-delete",
"+create-filter-view-condition": "+filter-view-update",
"+update-filter-view-condition": "+filter-view-update",
"+list-filter-view-conditions": "+filter-view-list",
"+get-filter-view-condition": "+filter-view-list",
"+delete-filter-view-condition": "+filter-view-update",
// dropdowns
"+set-dropdown": "+dropdown-set",
"+update-dropdown": "+dropdown-update",
"+get-dropdown": "+dropdown-get",
"+delete-dropdown": "+dropdown-delete",
// float images (media-upload folded into create)
"+media-upload": "+float-image-create",
"+create-float-image": "+float-image-create",
"+update-float-image": "+float-image-update",
"+get-float-image": "+float-image-list",
"+list-float-images": "+float-image-list",
"+delete-float-image": "+float-image-delete",
}
func applySheetsCompatGroups(svc *cobra.Command) {
svc.AddGroup(
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
&cobra.Group{
ID: sheetsDeprecatedGroupID,
Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update",
},
)
deprecated := make(map[string]struct{})
for _, s := range sheetsbackward.Shortcuts() {
deprecated[s.Command] = struct{}{}
}
for _, c := range svc.Commands() {
name := c.Name()
if _, ok := deprecated[name]; ok {
c.GroupID = sheetsDeprecatedGroupID
if repl := sheetsAliasReplacement[name]; repl != "" {
c.Short = c.Short + " (→ " + repl + ")"
}
continue
}
// Only the refactored shortcuts (all "+"-prefixed) belong in the current
// group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the
// auto-added help/completion ungrouped so cobra files them under
// "Additional Commands".
if len(name) > 0 && name[0] == '+' {
c.GroupID = sheetsCurrentGroupID
}
}
}
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
// alias so that invoking it records a process-level deprecation notice, which
// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users
// the --help grouping cannot: those whose pre-refactor skill calls +read /
// +write directly and never reads --help. Replacement targets come from
// sheetsAliasReplacement — the same single source of truth that drives the
// "(→ +new)" help pointers.
func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut {
for i := range list {
notice := &deprecation.Notice{
Command: list[i].Command,
Replacement: sheetsAliasReplacement[list[i].Command],
Skill: "lark-sheets",
}
// Record the notice as soon as the command's own logic runs, so it is
// surfaced even when Validate rejects the call — an out-of-date skill
// can pass pre-refactor argument shapes (e.g. a range without the new
// sheet-id prefix) and fail validation before Execute — and when
// --dry-run short-circuits before Execute. Both hooks store the same
// pointer, so setting it twice is harmless.
if origValidate := list[i].Validate; origValidate != nil {
list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error {
deprecation.SetPending(notice)
return origValidate(ctx, runtime)
}
}
if origExecute := list[i].Execute; origExecute != nil {
list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error {
deprecation.SetPending(notice)
return origExecute(ctx, runtime)
}
}
// The Validate/Execute wrappers above miss one path: a cobra-level
// required flag (MarkFlagRequired) that is absent fails at
// ValidateRequiredFlags, before RunE — so neither hook runs and the
// notice would be lost on exactly the "stale skill calls the old command
// and mis-supplies flags" case it exists for. OnInvoke runs from PreRunE,
// ahead of ValidateRequiredFlags, so the notice still surfaces there.
list[i].OnInvoke = func() { deprecation.SetPending(notice) }
}
return list
}

View File

@@ -5,6 +5,7 @@ package shortcuts
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -16,7 +17,9 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -46,8 +49,15 @@ func newRegisterTestProgramWithTipsHelp() *cobra.Command {
return program
}
func withRegisterTestArgs(t *testing.T, args ...string) {
t.Helper()
orig := os.Args
os.Args = append([]string{"lark-cli"}, args...)
t.Cleanup(func() { os.Args = orig })
}
func TestAllShortcutsScopesNotNil(t *testing.T) {
for _, s := range allShortcuts {
for _, s := range AllShortcuts() {
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
if !hasScopes {
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", s.Service, s.Command)
@@ -107,6 +117,30 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
}
}
func TestRegisterShortcuts_SkipsSheetsWhenInvocationTargetsOtherService(t *testing.T) {
program := &cobra.Command{Use: "root"}
withRegisterTestArgs(t, "auth", "status")
RegisterShortcuts(program, newRegisterTestFactory(t))
if _, _, err := program.Find([]string{"sheets", "+workbook-info"}); err == nil {
t.Fatal("unexpected sheets shortcut mounted for non-sheets invocation")
}
}
func TestRegisterShortcuts_IncludesSheetsWhenInvocationTargetsSheets(t *testing.T) {
program := &cobra.Command{Use: "root"}
withRegisterTestArgs(t, "sheets", "+workbook-info")
RegisterShortcuts(program, newRegisterTestFactory(t))
cmd, _, err := program.Find([]string{"sheets", "+workbook-info"})
if err != nil {
t.Fatalf("find sheets shortcut: %v", err)
}
if cmd == nil || cmd.Name() != "+workbook-info" {
t.Fatalf("sheets shortcut not mounted: %#v", cmd)
}
}
// Service-level cobra commands created by RegisterShortcuts must carry
// the cmdmeta.Domain annotation so plugin Selectors (platform.ByDomain)
// and Rule.Allow path-globs can resolve a command's business domain.
@@ -471,3 +505,152 @@ func TestGenerateShortcutsJSON(t *testing.T) {
}
t.Logf("wrote %d bytes to %s", len(data), output)
}
// applySheetsCompatGroups must split the sheets service into a current group
// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases),
// append a "(→ +new)" migration pointer to each alias, and leave non-"+"
// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them
// under "Additional Commands".
func TestApplySheetsCompatGroups(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"}
aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"}
metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"}
svc.AddCommand(newCmd, aliasCmd, metaCmd)
applySheetsCompatGroups(svc)
if !svc.ContainsGroup(sheetsCurrentGroupID) {
t.Errorf("current group %q not registered", sheetsCurrentGroupID)
}
if !svc.ContainsGroup(sheetsDeprecatedGroupID) {
t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID)
}
if newCmd.GroupID != sheetsCurrentGroupID {
t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID)
}
if aliasCmd.GroupID != sheetsDeprecatedGroupID {
t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID)
}
if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") {
t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short)
}
if metaCmd.GroupID != "" {
t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID)
}
}
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
// heading (telling users to update their skill) plus the per-alias migration
// pointers, while keeping the refactored shortcuts under Available Commands.
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
sheetsCmd, _, err := program.Find([]string{"sheets"})
if err != nil {
t.Fatalf("find sheets command: %v", err)
}
var out bytes.Buffer
sheetsCmd.SetOut(&out)
if err := sheetsCmd.Help(); err != nil {
t.Fatalf("sheets help failed: %v", err)
}
got := out.String()
for _, want := range []string{
"Available Commands:",
"Deprecated pre-refactor commands",
"update your lark-sheets skill",
"+read",
"(→ +cells-get)",
"+write",
"(→ +cells-set)",
} {
if !strings.Contains(got, want) {
t.Fatalf("sheets help missing %q:\n%s", want, got)
}
}
}
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
// invoking it records a process-level deprecation notice (reusing
// sheetsAliasReplacement for the migration target) while still calling the
// original Execute. cmd/root.go reads that notice into the JSON "_notice".
func TestWrapSheetsBackwardDeprecation(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
called := false
in := []common.Shortcut{{
Service: "sheets",
Command: "+read",
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
called = true
return nil
},
}}
out := wrapSheetsBackwardDeprecation(in)
if len(out) != 1 {
t.Fatalf("wrapped list len = %d, want 1", len(out))
}
if deprecation.GetPending() != nil {
t.Fatal("notice set before wrapped Execute ran")
}
if err := out[0].Execute(context.Background(), nil); err != nil {
t.Fatalf("wrapped Execute returned error: %v", err)
}
if !called {
t.Fatal("original Execute was not invoked by the wrapper")
}
dep := deprecation.GetPending()
if dep == nil {
t.Fatal("expected a pending deprecation notice after Execute")
}
if dep.Command != "+read" {
t.Errorf("notice Command = %q, want +read", dep.Command)
}
if dep.Replacement != "+cells-get" {
t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement)
}
if dep.Skill != "lark-sheets" {
t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill)
}
}
// The wrapper must also decorate Validate, so an out-of-date skill whose
// pre-refactor argument shape fails validation (before Execute) still gets the
// deprecation notice in its error envelope.
func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
validated := false
in := []common.Shortcut{{
Service: "sheets",
Command: "+write",
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
validated = true
return nil
},
}}
out := wrapSheetsBackwardDeprecation(in)
if out[0].Validate == nil {
t.Fatal("Validate hook was dropped by the wrapper")
}
if err := out[0].Validate(context.Background(), nil); err != nil {
t.Fatalf("wrapped Validate returned error: %v", err)
}
if !validated {
t.Fatal("original Validate was not invoked")
}
dep := deprecation.GetPending()
if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" {
t.Fatalf("Validate hook did not record expected notice: %#v", dep)
}
}

View File

@@ -0,0 +1,239 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`)
cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`)
cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`)
colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`)
rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`)
cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`)
)
var sheetRangeSeparatorReplacer = strings.NewReplacer(`\`, "!", `\!`, "!", "", "!")
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
if err != nil {
return "", err
}
sheets, _ := data["sheets"].([]interface{})
if len(sheets) > 0 {
sheet, _ := sheets[0].(map[string]interface{})
if id, ok := sheet["sheet_id"].(string); ok && id != "" {
return id, nil
}
}
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
}
// extractSpreadsheetToken extracts spreadsheet token from URL.
func extractSpreadsheetToken(input string) string {
input = strings.TrimSpace(input)
prefixes := []string{"/sheets/", "/spreadsheets/"}
for _, prefix := range prefixes {
if idx := strings.Index(input, prefix); idx >= 0 {
token := input[idx+len(prefix):]
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
token = token[:idx2]
}
return token
}
}
return input
}
func normalizeSheetRange(sheetID, input string) string {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID == "" {
return input
}
if looksLikeRelativeRange(input) {
return sheetID + "!" + input
}
return input
}
func normalizePointRange(sheetID, input string) string {
input = normalizeSheetRange(sheetID, input)
if input == "" {
return input
}
rangeSheetID, subRange, ok := splitSheetRange(input)
if !ok || !singleCellRangePattern.MatchString(subRange) {
return input
}
return rangeSheetID + "!" + subRange + ":" + subRange
}
func normalizeWriteRange(sheetID, input string, values interface{}) string {
rows, cols := matrixDimensions(values)
input = normalizeSheetRangeSeparators(input)
if input == "" {
return buildRectRange(sheetID, "A1", rows, cols)
}
input = normalizeSheetRange(sheetID, input)
rangeSheetID, subRange, ok := splitSheetRange(input)
if !ok {
return buildRectRange(input, "A1", rows, cols)
}
if singleCellRangePattern.MatchString(subRange) {
return buildRectRange(rangeSheetID, subRange, rows, cols)
}
return input
}
func validateSheetRangeInput(sheetID, input string) error {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID != "" {
return nil
}
if looksLikeRelativeRange(input) {
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
}
return nil
}
// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are
// invalid for single-cell operations like write-image. Empty and single-cell
// values pass through.
func validateSingleCellRange(input string) error {
input = normalizeSheetRangeSeparators(input)
if input == "" {
return nil
}
// Extract the sub-range after the sheet ID prefix, if present.
subRange := input
if _, sr, ok := splitSheetRange(input); ok {
subRange = sr
}
if cellSpanRangePattern.MatchString(subRange) {
parts := strings.SplitN(subRange, ":", 2)
if strings.EqualFold(parts[0], parts[1]) {
return nil
}
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
}
return nil
}
func looksLikeRelativeRange(input string) bool {
input = normalizeSheetRangeSeparators(input)
if input == "" {
return false
}
return singleCellRangePattern.MatchString(input) ||
cellSpanRangePattern.MatchString(input) ||
cellToColRangePattern.MatchString(input) ||
colSpanRangePattern.MatchString(input) ||
rowSpanRangePattern.MatchString(input)
}
func splitSheetRange(input string) (sheetID, subRange string, ok bool) {
parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
func normalizeSheetRangeSeparators(input string) string {
input = strings.TrimSpace(input)
if input == "" {
return input
}
return sheetRangeSeparatorReplacer.Replace(input)
}
func buildRectRange(sheetID, anchor string, rows, cols int) string {
if sheetID == "" {
return ""
}
if rows < 1 {
rows = 1
}
if cols < 1 {
cols = 1
}
endCell, err := offsetCell(anchor, rows-1, cols-1)
if err != nil {
return sheetID
}
return sheetID + "!" + anchor + ":" + endCell
}
func matrixDimensions(values interface{}) (rows, cols int) {
rowList, ok := values.([]interface{})
if !ok || len(rowList) == 0 {
return 1, 1
}
rows = len(rowList)
for _, row := range rowList {
if cells, ok := row.([]interface{}); ok && len(cells) > cols {
cols = len(cells)
}
}
if cols == 0 {
cols = 1
}
return rows, cols
}
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
if len(matches) != 3 {
return "", fmt.Errorf("invalid cell reference: %s", cell)
}
colIndex := columnNameToIndex(matches[1])
if colIndex < 1 {
return "", fmt.Errorf("invalid column: %s", matches[1])
}
rowIndex, err := strconv.Atoi(matches[2])
if err != nil {
return "", err
}
return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil
}
func columnNameToIndex(name string) int {
name = strings.ToUpper(strings.TrimSpace(name))
if name == "" {
return 0
}
index := 0
for _, r := range name {
if r < 'A' || r > 'Z' {
return 0
}
index = index*26 + int(r-'A'+1)
}
return index
}
func columnIndexToName(index int) string {
if index < 1 {
return ""
}
var out []byte
for index > 0 {
index--
out = append([]byte{byte('A' + index%26)}, out...)
index /= 26
}
return string(out)
}

View File

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

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