Compare commits

..

7 Commits

Author SHA1 Message Date
shanglei
52dc09af95 style(sidecar): gofmt hmac_test.go
Align comment spacing flagged by the fast-gate gofmt check.
2026-06-02 20:16:42 +08:00
shanglei
07da0c8090 feat(sidecar): support remote HTTPS sidecar addresses
Relax the auth-sidecar proxy address policy so a remote central sidecar
reachable over TLS can be used, while keeping existing same-host plaintext
behavior unchanged.

- ValidateProxyAddr: allow https:// to any host (cross-machine); http://
  and bare host:port stay same-host only; userinfo/path/query/fragment
  remain rejected.
- Add ProxyScheme and route the interceptor URL rewrite through the
  configured scheme (https for remote, http for same-host). ProxyScheme
  parses the address so a mixed-case HTTPS:// cannot silently downgrade to
  plaintext HTTP.
- Update LARKSUITE_CLI_AUTH_PROXY doc and server-demo README for the new
  policy; refresh the package comment.
- Tests: case-insensitive scheme, IPv6 https, https userinfo rejection,
  query/fragment rejection, ProxyHost https forms, and end-to-end
  interceptor scheme selection.
2026-06-02 20:13:47 +08:00
91-enjoy
0aa9e96d18 feat: resolve markdown blank-line formatting inconsistency in post messages (#1216)
Simplifies the markdown-to-post rendering pipeline in the IM shortcut. The previous
implementation split markdown at blank-line boundaries into multiple post paragraphs,
using zero-width space (\u200B) sentinel characters to preserve visual spacing.
While well-intentioned, this approach introduced fragility around edge cases such as
blank lines inside fenced code blocks, messages with only blank lines, and interactions
with the heading-normalization pass. This change consolidates rendering back into a
single {"tag":"md"} segment, making the output more predictable, the code significantly
easier to follow, and the test surface easier to maintain.
Change-Id: Ic2870ecbcb31ae7d36121f120102f2ff964f5169
2026-06-02 17:49:45 +08:00
zgz2048
e57d97f341 docs: optimize base skill references (#1171) 2026-06-02 17:30:10 +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
293 changed files with 14889 additions and 33850 deletions

View File

@@ -117,13 +117,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd) installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals) RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {

View File

@@ -16,7 +16,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output" "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. // 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) // Step 1: Request app registration (begin)
// Use the shared proxy-plugin-aware transport so registration traffic is not // Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode. // a bypass of proxy plugin mode.
httpClient := util.NewHTTPClient(0) httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut) authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil { if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err) 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/core"
"github.com/larksuite/cli/internal/identitydiag" "github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/internal/util"
) )
// DoctorOptions holds inputs for the doctor command. // 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 // Use the shared proxy-plugin-aware transport so connectivity checks reflect
// the real egress path (and are blocked when proxy plugin fails closed). // the real egress path (and are blocked when proxy plugin fails closed).
httpClient := util.NewHTTPClient(0) httpClient := transport.NewHTTPClient(0)
mcpURL := ep.MCP + "/mcp" mcpURL := ep.MCP + "/mcp"
type probeResult struct { type probeResult struct {

View File

@@ -10,7 +10,6 @@ import (
eventlib "github.com/larksuite/cli/internal/event" eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
) )
const maxSuggestions = 3 const maxSuggestions = 3
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0}) hits = append(hits, match{def.Key, 0})
continue continue
} }
if d := suggest.Levenshtein(input, def.Key); d <= threshold { if d := levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d}) hits = append(hits, match{def.Key, d})
} }
} }
@@ -70,3 +69,34 @@ func unknownEventKeyErr(key string) error {
"Run 'lark-cli event list' to see available keys.", "Run 'lark-cli event list' to see available keys.",
) )
} }
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}

View File

@@ -10,6 +10,27 @@ import (
_ "github.com/larksuite/cli/events" _ "github.com/larksuite/cli/events"
) )
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) { func TestSuggestEventKeys(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
}
}

View File

@@ -24,10 +24,8 @@ import (
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
const rootLong = `lark-cli — Lark/Feishu CLI tool. const rootLong = `lark-cli — Lark/Feishu CLI tool.
@@ -310,12 +308,6 @@ func asExitError(err error) *output.ExitError {
func installUnknownSubcommandGuard(cmd *cobra.Command) { func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil { if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
cmd.RunE = unknownSubcommandRunE cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
// consumes no flags itself, so unknown flags belong to the (missing)
// subcommand; whitelisting them here prevents cobra from erroring on the
// flag first and printing usage instead of our structured suggestion.
cmd.FParseErrWhitelist.UnknownFlags = true
if cmd.Annotations == nil { if cmd.Annotations == nil {
cmd.Annotations = map[string]string{} cmd.Annotations = map[string]string{}
} }
@@ -339,12 +331,10 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
} }
unknown := args[0] unknown := args[0]
available := availableSubcommandNames(cmd) available := availableSubcommandNames(cmd)
suggestions := suggest.Closest(unknown, available, 6)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()) msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath()) hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(suggestions) > 0 { if len(available) > 0 {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)", hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
strings.Join(suggestions, ", "), cmd.CommandPath())
} }
return &output.ExitError{ return &output.ExitError{
Code: output.ExitValidation, Code: output.ExitValidation,
@@ -355,7 +345,6 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
Detail: map[string]any{ Detail: map[string]any{
"unknown": unknown, "unknown": unknown,
"command_path": cmd.CommandPath(), "command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available, "available": available,
}, },
}, },
@@ -378,81 +367,6 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
return subs return subs
} }
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return rest, true
}
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
// the valid_flags detail).
func visibleFlagNames(c *cobra.Command) []string {
var names []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
names = append(names, f.Name)
}
})
sort.Strings(names)
return names
}
// installTipsHelpFunc wraps the default help function to append a TIPS section // installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips. It also force-shows global // when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile) // flags that are normally hidden in single-app mode (currently --profile)

View File

@@ -113,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) { if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message) t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
} }
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
// back to pointing at --help; the full machine-readable list lives in t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
// detail.available below (which also excludes hidden commands). }
if !strings.Contains(exitErr.Detail.Hint, "--help") { if strings.Contains(exitErr.Detail.Hint, "+secret") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint) t.Error("hidden commands must not appear in the hint")
} }
detail, ok := exitErr.Detail.Detail.(map[string]any) detail, ok := exitErr.Detail.Detail.(map[string]any)

View File

@@ -4,11 +4,12 @@
//go:build authsidecar //go:build authsidecar
// Package sidecar provides a transport interceptor for the auth sidecar // Package sidecar provides a transport interceptor for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all // proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
// outgoing requests are rewritten to the sidecar address. The interceptor // URL), all outgoing requests are rewritten to the sidecar address. The
// strips placeholder credentials, injects proxy headers, and signs each // interceptor strips placeholder credentials, injects proxy headers, and
// request with HMAC-SHA256. No custom DialContext is needed — Go's // signs each request with HMAC-SHA256. No custom DialContext is needed —
// standard http.Transport connects to the sidecar via plain HTTP. // Go's standard http.Transport connects to the sidecar via HTTP, or via
// HTTPS (TLS) when the sidecar address is an https:// URL.
package sidecar package sidecar
import ( import (
@@ -46,15 +47,17 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
} }
key := os.Getenv(envvars.CliProxyKey) key := os.Getenv(envvars.CliProxyKey)
return &Interceptor{ return &Interceptor{
key: []byte(key), key: []byte(key),
sidecarHost: sidecar.ProxyHost(proxyAddr), sidecarHost: sidecar.ProxyHost(proxyAddr),
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
} }
} }
// Interceptor rewrites requests for the sidecar proxy. // Interceptor rewrites requests for the sidecar proxy.
type Interceptor struct { type Interceptor struct {
key []byte // HMAC signing key key []byte // HMAC signing key
sidecarHost string // sidecar host:port for URL rewriting sidecarHost string // sidecar host[:port] for URL rewriting
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
} }
// PreRoundTrip rewrites the request for sidecar routing when it carries a // PreRoundTrip rewrites the request for sidecar routing when it carries a
@@ -130,8 +133,13 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
req.Header.Set(sidecar.HeaderProxyTimestamp, ts) req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderProxySignature, sig) req.Header.Set(sidecar.HeaderProxySignature, sig)
// 5. Rewrite URL to route through sidecar // 5. Rewrite URL to route through sidecar. Scheme follows the configured
req.URL.Scheme = "http" // proxy address: https for a remote (TLS) sidecar, http for a same-host one.
scheme := i.sidecarScheme
if scheme == "" {
scheme = "http"
}
req.URL.Scheme = scheme
req.URL.Host = i.sidecarHost req.URL.Host = i.sidecarHost
return nil // no post-hook needed return nil // no post-hook needed

View File

@@ -7,11 +7,13 @@ package sidecar
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"io" "io"
"net/http" "net/http"
"testing" "testing"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar" "github.com/larksuite/cli/sidecar"
) )
@@ -97,6 +99,54 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
} }
} }
// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
// rewrites the request to https://<remote-host>, while still preserving the
// original target and signing the request.
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
key := []byte("test-key-for-hmac-signing-32byte!")
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
interceptor.PreRoundTrip(req)
if req.URL.Scheme != "https" {
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
}
if req.URL.Host != "sidecar.mycorp.com" {
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
}
// Original target still preserved for the sidecar to forward upstream.
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
}
// Request is still signed.
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
t.Error("signature header should be set")
}
}
// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
// (mixed-case) https proxy address must produce an interceptor that rewrites to
// https, never silently downgrading a remote sidecar to plaintext http.
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
t.Setenv(envvars.CliProxyKey, "key")
ic := (&Provider{}).ResolveInterceptor(context.Background())
si, ok := ic.(*Interceptor)
if !ok || si == nil {
t.Fatalf("expected *Interceptor, got %T", ic)
}
if si.sidecarScheme != "https" {
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
}
if si.sidecarHost != "sidecar.mycorp.com" {
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
}
}
func TestInterceptor_BotIdentity(t *testing.T) { func TestInterceptor_BotIdentity(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}

View File

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

View File

@@ -5,7 +5,6 @@ package cmdpolicy
import ( import (
"github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/suggest"
) )
// suggestRisk returns the closest valid Risk literal by edit distance // suggestRisk returns the closest valid Risk literal by edit distance
@@ -21,9 +20,9 @@ func suggestRisk(bad string) string {
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite, platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
} }
best := string(candidates[0]) best := string(candidates[0])
bestDist := suggest.Levenshtein(lowered, best) bestDist := levenshtein(lowered, best)
for _, c := range candidates[1:] { for _, c := range candidates[1:] {
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist { if d := levenshtein(lowered, string(c)); d < bestDist {
bestDist, best = d, string(c) bestDist, best = d, string(c)
} }
} }
@@ -41,3 +40,47 @@ func toLower(s string) string {
} }
return string(b) return string(b)
} }
// levenshtein computes the classic edit distance between two strings.
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
// makes raw performance irrelevant — clarity beats trickiness here.
func levenshtein(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
prev := make([]int, len(b)+1)
curr := make([]int, len(b)+1)
for j := 0; j <= len(b); j++ {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
curr[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min3(
prev[j]+1, // deletion
curr[j-1]+1, // insertion
prev[j-1]+cost, // substitution
)
}
prev, curr = curr, prev
}
return prev[len(b)]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}

View File

@@ -29,3 +29,23 @@ func TestSuggestRisk(t *testing.T) {
} }
} }
} }
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"wrtie", "write", 2},
{"kitten", "sitting", 3},
}
for _, c := range cases {
got := levenshtein(c.a, c.b)
if got != c.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}

View File

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

View File

@@ -9,7 +9,7 @@ import (
"time" "time"
exttransport "github.com/larksuite/cli/extension/transport" 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 // RetryTransport is an http.RoundTripper that retries on 5xx responses
@@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
if t.Base != nil { if t.Base != nil {
return t.Base return t.Base
} }
return util.FallbackTransport() return transport.Fallback()
} }
func (t *RetryTransport) delay() time.Duration { func (t *RetryTransport) delay() time.Duration {
@@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
if t.Base != nil { if t.Base != nil {
return t.Base.RoundTrip(req) return t.Base.RoundTrip(req)
} }
return util.FallbackTransport().RoundTrip(req) return transport.Fallback().RoundTrip(req)
} }
// BuildHeaderTransport is an http.RoundTripper that force-writes the // 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 { if t.Base != nil {
return t.Base.RoundTrip(req) return t.Base.RoundTrip(req)
} }
return util.FallbackTransport().RoundTrip(req) return transport.Fallback().RoundTrip(req)
} }
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security // SecurityHeaderTransport is an http.RoundTripper that injects CLI security
@@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
if t.Base != nil { if t.Base != nil {
return t.Base return t.Base
} }
return util.FallbackTransport() return transport.Fallback()
} }
// RoundTrip implements http.RoundTripper. // 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, // TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
// the transport still sets X-Cli-Build and routes the request through // 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. // branch in RoundTrip that is otherwise unreachable with a non-nil Base.
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) { func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
var receivedBuild string var receivedBuild string

View File

@@ -13,7 +13,7 @@ const (
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE" CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Sidecar proxy (auth proxy mode) // Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384" CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar address http(s)://host[:port]; plaintext http is same-host only, a remote sidecar must use https. e.g. "http://127.0.0.1:16384" or "https://sidecar.mycorp.com"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
// Content safety scanning mode // Content safety scanning mode

View File

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

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package suggest provides the shared "did you mean" primitives: a rune-aware
// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the
// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each
// carrying their own copy.
package suggest
import "sort"
// Levenshtein computes the classic edit distance between two strings. It is
// rune-aware, so it is correct for multi-byte input.
func Levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}
// Closest returns up to maxN of candidates that plausibly match typed, ranked
// by shared-prefix length (desc) then edit distance (asc), keeping only
// reasonably-close ones.
//
// Shared prefix is weighted first on purpose: hallucinated names are often
// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search",
// "--with-styles" vs nothing close), where the common prefix is the strongest
// signal of intent that raw edit distance misses.
func Closest(typed string, candidates []string, maxN int) []string {
type scored struct {
name string
prefix int
dist int
}
limit := editLimit(typed)
ranked := make([]scored, 0, len(candidates))
for _, c := range candidates {
p := sharedPrefixLen(typed, c)
d := Levenshtein(typed, c)
// Keep only plausible matches: a meaningful shared prefix, or an edit
// distance within budget. Drop everything else so the hint stays short.
if p >= 3 || d <= limit {
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
}
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].prefix != ranked[j].prefix {
return ranked[i].prefix > ranked[j].prefix
}
if ranked[i].dist != ranked[j].dist {
return ranked[i].dist < ranked[j].dist
}
return ranked[i].name < ranked[j].name
})
if maxN <= 0 || maxN > len(ranked) {
maxN = len(ranked)
}
out := make([]string, 0, maxN)
for _, s := range ranked[:maxN] {
out = append(out, s.name)
}
return out
}
// editLimit allows roughly one third of the typed length in edits (min 2), so
// short names tolerate a couple of typos and longer ones proportionally more.
func editLimit(s string) int {
if l := len([]rune(s)) / 3; l > 2 {
return l
}
return 2
}
func sharedPrefixLen(a, b string) int {
ra, rb := []rune(a), []rune(b)
n := 0
for n < len(ra) && n < len(rb) && ra[n] == rb[n] {
n++
}
return n
}

View File

@@ -1,74 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package suggest
import (
"slices"
"testing"
)
func TestClosest_HallucinatedSharesPrefix(t *testing.T) {
cmds := []string{
"+cells-get", "+cells-set", "+cells-search", "+cells-replace",
"+cells-clear", "+cells-merge", "+csv-get", "+chart-create",
"+pivot-create", "+sheet-info",
}
// "+cells-find" is semantically +cells-search but lexically far; the shared
// "+cells-" prefix should still surface the right family (incl. +cells-search).
got := Closest("+cells-find", cmds, 6)
if len(got) == 0 || len(got) > 6 {
t.Fatalf("expected 1..6 suggestions, got %v", got)
}
if !slices.Contains(got, "+cells-search") {
t.Errorf("expected +cells-search among suggestions, got %v", got)
}
for _, s := range got {
if len(s) < 7 || s[:7] != "+cells-" {
t.Errorf("suggestion %q does not share the +cells- prefix", s)
}
}
}
func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) {
got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3)
if len(got) == 0 || got[0] != "+cells-get" {
t.Errorf("expected +cells-get first for typo +cell-get, got %v", got)
}
}
func TestClosest_NoPlausibleMatch(t *testing.T) {
if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 {
t.Errorf("expected no suggestions for unrelated input, got %v", got)
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"kitten", "sitting", 3},
{"cell-get", "cells-get", 1},
{"--query", "--find", 5},
{"飞书", "飞书", 0}, // rune-aware: multi-byte equal
{"飞书", "飞s", 1}, // one rune substitution, not byte count
}
for _, c := range cases {
if d := Levenshtein(c.a, c.b); d != c.want {
t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want)
}
}
}
func TestSharedPrefixLen(t *testing.T) {
if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 {
t.Errorf("sharedPrefixLen = %d, want 7", got)
}
if got := sharedPrefixLen("abc", "xyz"); got != 0 {
t.Errorf("sharedPrefixLen = %d, want 0", got)
}
}

View File

@@ -1,14 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd. // Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT // 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: // Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy // proxy, optionally trusting an extra root CA PEM bundle for TLS-inspection
// - trusting an additional root CA PEM bundle for MITM/inspection proxies // proxies, and fails closed on misconfiguration. Environment variables override
// // matching values from proxy_config.json.
// Environment variables override matching values from proxy_config.json. package transport
package proxyplugin
import ( import (
"encoding/json" "encoding/json"
@@ -222,21 +223,6 @@ func (c *Config) proxyURL() (*url.URL, error) {
return u, nil 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. // ApplyToTransport clones base and applies proxy plugin settings to the clone.
// Caller owns the returned *http.Transport. // Caller owns the returned *http.Transport.
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) { func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd. // Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package proxyplugin package transport
import ( import (
"net/http" "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. // Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package proxyplugin package transport
import ( import (
"crypto/tls" "crypto/tls"

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd. // Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package proxyplugin package transport
import ( import (
"io" "io"
@@ -20,21 +20,21 @@ func resetProxyPluginState() {
cachedBlockedTransport = sync.OnceValue(buildBlockedTransport) cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
} }
func TestSharedTransport_NotConfigured(t *testing.T) { func TestPluginTransport_NotConfigured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
resetProxyPluginState() resetProxyPluginState()
tr, ok := SharedTransport() tr, ok := pluginTransport()
if ok { if ok {
t.Fatalf("SharedTransport() ok = true, want false") t.Fatalf("pluginTransport() ok = true, want false")
} }
if tr != nil { 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()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
resetProxyPluginState() resetProxyPluginState()
@@ -46,13 +46,13 @@ func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
"LARKSUITE_CLI_CA_PATH": "" "LARKSUITE_CLI_CA_PATH": ""
}`), 0600) }`), 0600)
rt, ok := SharedTransport() rt, ok := pluginTransport()
if !ok { if !ok {
t.Fatal("SharedTransport() ok = false, want true") t.Fatal("pluginTransport() ok = false, want true")
} }
tr, ok := rt.(*http.Transport) tr, ok := rt.(*http.Transport)
if !ok { 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"}}) u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil { 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()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
resetProxyPluginState() resetProxyPluginState()
@@ -72,12 +72,12 @@ func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
writeFile(t, Path(), []byte(`{`), 0600) writeFile(t, Path(), []byte(`{`), 0600)
rt, ok := SharedTransport() rt, ok := pluginTransport()
if !ok { if !ok {
t.Fatal("SharedTransport() ok = false, want true") t.Fatal("pluginTransport() ok = false, want true")
} }
if rt == http.DefaultTransport { 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"}}) resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err == nil { 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()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
resetProxyPluginState() resetProxyPluginState()
writeFile(t, Path(), []byte(`{`), 0600) writeFile(t, Path(), []byte(`{`), 0600)
a, ok := SharedTransport() a, ok := pluginTransport()
if !ok { if !ok {
t.Fatal("SharedTransport() ok = false, want true") t.Fatal("pluginTransport() ok = false, want true")
} }
b, ok := SharedTransport() b, ok := pluginTransport()
if !ok { if !ok {
t.Fatal("SharedTransport() ok = false, want true") t.Fatal("pluginTransport() ok = false, want true")
} }
if a != b { 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 // TestPluginTransport_InvalidConfigBlockerIsConcreteTransport guards the
// fail-closed invariant that util.FallbackTransport relies on: even when // fail-closed invariant that Fallback relies on: even when
// http.DefaultTransport is not an *http.Transport, an invalid proxy config must // 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 // 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. // 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()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
resetProxyPluginState() resetProxyPluginState()
@@ -163,12 +163,12 @@ func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
writeFile(t, Path(), []byte(`{`), 0600) writeFile(t, Path(), []byte(`{`), 0600)
rt, ok := SharedTransport() rt, ok := pluginTransport()
if !ok { if !ok {
t.Fatal("SharedTransport() ok = false, want true") t.Fatal("pluginTransport() ok = false, want true")
} }
if _, isTransport := rt.(*http.Transport); !isTransport { 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. // Must remain fail-closed.
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) 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. // Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package util package transport
import ( import (
"bytes" "bytes"
"net/http"
"os"
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"github.com/larksuite/cli/internal/envvars" "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. // TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
func TestDetectProxyEnv(t *testing.T) { func TestDetectProxyEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) 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. // TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
func TestWarnIfProxied_WithProxy(t *testing.T) { func TestWarnIfProxied_WithProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
// Reset the once guard for this test resetProxyPluginState()
proxyWarningOnce = sync.Once{} proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128") t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
var buf bytes.Buffer var buf bytes.Buffer
WarnIfProxied(&buf, true) WarnIfProxied(&buf)
out := buf.String() out := buf.String()
if out == "" { if out == "" {
@@ -166,6 +62,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
func TestWarnIfProxied_WithoutProxy(t *testing.T) { func TestWarnIfProxied_WithoutProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{} proxyWarningOnce = sync.Once{}
for _, k := range proxyEnvKeys { for _, k := range proxyEnvKeys {
@@ -173,41 +70,25 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
} }
var buf bytes.Buffer var buf bytes.Buffer
WarnIfProxied(&buf, true) WarnIfProxied(&buf)
if buf.Len() != 0 { if buf.Len() != 0 {
t.Errorf("expected no output when no proxy is set, got: %s", buf.String()) t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
} }
} }
func TestWarnIfProxied_SilentWhenNonInteractive(t *testing.T) { // TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings.
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.
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{} proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://proxy:8080") t.Setenv("HTTPS_PROXY", "http://proxy:8080")
t.Setenv(EnvNoProxy, "1") t.Setenv(EnvNoProxy, "1")
var buf bytes.Buffer var buf bytes.Buffer
WarnIfProxied(&buf, true) WarnIfProxied(&buf)
if buf.Len() != 0 { if buf.Len() != 0 {
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String()) 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) { func TestWarnIfProxied_OnlyOnce(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{} proxyWarningOnce = sync.Once{}
t.Setenv("HTTP_PROXY", "http://proxy:1234") t.Setenv("HTTP_PROXY", "http://proxy:1234")
var buf bytes.Buffer var buf bytes.Buffer
WarnIfProxied(&buf, true) WarnIfProxied(&buf)
first := buf.String() first := buf.String()
WarnIfProxied(&buf, true) WarnIfProxied(&buf)
second := buf.String() second := buf.String()
if first == "" { if first == "" {
@@ -255,7 +137,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
t.Setenv(EnvNoProxy, "1") t.Setenv(EnvNoProxy, "1")
var buf bytes.Buffer var buf bytes.Buffer
WarnIfProxied(&buf, true) WarnIfProxied(&buf)
out := buf.String() out := buf.String()
if !strings.Contains(out, "127.0.0.1:3128") { 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 // 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) { func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
@@ -287,7 +169,7 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
t.Cleanup(func() { proxyPluginStatus = old }) t.Cleanup(func() { proxyPluginStatus = old })
var buf bytes.Buffer var buf bytes.Buffer
WarnIfProxied(&buf, true) WarnIfProxied(&buf)
out := buf.String() out := buf.String()
if !strings.Contains(out, "custom CA") { 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 // TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin
// warning never leaks credentials embedded in the configured proxy address. // warning never leaks credentials embedded in the configured proxy address.
func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) { func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
@@ -332,7 +195,7 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
t.Cleanup(func() { proxyPluginStatus = old }) t.Cleanup(func() { proxyPluginStatus = old })
var buf bytes.Buffer var buf bytes.Buffer
WarnIfProxied(&buf, true) WarnIfProxied(&buf)
out := buf.String() out := buf.String()
if strings.Contains(out, "s3cret") { if strings.Contains(out, "s3cret") {
@@ -348,8 +211,6 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats. // TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
func TestRedactProxyURL(t *testing.T) { func TestRedactProxyURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
tests := []struct { tests := []struct {
input string input string
want string want string
@@ -376,12 +237,13 @@ func TestRedactProxyURL(t *testing.T) {
func TestWarnIfProxied_RedactsCredentials(t *testing.T) { func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t) unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{} proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080") t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
var buf bytes.Buffer var buf bytes.Buffer
WarnIfProxied(&buf, true) WarnIfProxied(&buf)
out := buf.String() out := buf.String()
if bytes.Contains([]byte(out), []byte("s3cret")) { if bytes.Contains([]byte(out), []byte("s3cret")) {

View File

@@ -17,7 +17,7 @@ import (
"time" "time"
"github.com/larksuite/cli/internal/core" "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/validate"
"github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/internal/vfs"
) )
@@ -64,7 +64,7 @@ func httpClient() *http.Client {
} }
return &http.Client{ return &http.Client{
Timeout: fetchTimeout, 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{ Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true}, {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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")

View File

@@ -25,6 +25,9 @@ var BaseAdvpermEnable = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true}, {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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank") 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: "without-content", Type: "bool", Desc: "copy structure only"},
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"}, {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, DryRun: dryRunBaseCopy,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseCopy(runtime) return executeBaseCopy(runtime)

View File

@@ -22,6 +22,10 @@ var BaseBaseCreate = common.Shortcut{
{Name: "folder-token", Desc: "folder token for destination"}, {Name: "folder-token", Desc: "folder token for destination"},
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"}, {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, DryRun: dryRunBaseCreate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseCreate(runtime) return executeBaseCreate(runtime)

View File

@@ -20,7 +20,12 @@ var BaseDataQuery = common.Shortcut{
AuthTypes: authTypes(), AuthTypes: authTypes(),
Flags: []common.Flag{ Flags: []common.Flag{
baseTokenFlag(true), 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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
var dsl map[string]interface{} 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") { if !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err) 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) t.Fatalf("err=%v", err)
} }
if strings.Contains(err.Error(), "array") { if strings.Contains(err.Error(), "array") {

View File

@@ -25,6 +25,9 @@ var BaseFormCreate = common.Shortcut{
{Name: "name", Desc: "form name", Required: true}, {Name: "name", Desc: "form name", Required: true},
{Name: "description", Desc: `form description (plain text or markdown link like [text](https://example.com))`}, {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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI(). return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms"). 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: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form 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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI(). return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). 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{ Flags: []common.Flag{
{Name: "base-token", Desc: "Base token (base_token)", Required: true}, {Name: "base-token", Desc: "Base token (base_token)", Required: true},
{Name: "table-id", Desc: "table ID", 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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI(). return common.NewDryRunAPI().

View File

@@ -25,6 +25,9 @@ var BaseFormQuestionsDelete = common.Shortcut{
{Name: "form-id", Desc: "form ID", Required: true}, {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}, {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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI(). return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). 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: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form 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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI(). return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). 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"}, Scopes: []string{"base:app:read"},
AuthTypes: authTypes(), AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true)}, 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 { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseGet(runtime) return executeBaseGet(runtime)
}, },

View File

@@ -25,7 +25,12 @@ var BaseRoleCreate = common.Shortcut{
AuthTypes: []string{"user", "bot"}, AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true}, {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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { 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: "base-token", Desc: "base token", Required: true},
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", 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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank") 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: "base-token", Desc: "base token", Required: true},
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", 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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")

View File

@@ -26,6 +26,10 @@ var BaseRoleList = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true}, {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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")

View File

@@ -26,7 +26,13 @@ var BaseRoleUpdate = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true}, {Name: "base-token", Desc: "base token", Required: true},
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", 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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { 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 { 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 { 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) { func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
parent := &cobra.Command{Use: "base"} parent := &cobra.Command{Use: "base"}
BaseFieldCreate.Mount(parent, &cmdutil.Factory{}) BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
@@ -251,20 +270,19 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
name: "record search", name: "record search",
shortcut: BaseRecordSearch, shortcut: BaseRecordSearch,
wantHelp: []string{ wantHelp: []string{
"requires keyword/search_fields", `record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
"optional select_fields/view_id/offset/limit", "for keyword search only",
"output format: markdown (default) | json", "output format: markdown (default) | json",
}, },
wantTips: []string{ wantTips: []string{
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`, "Happy path fields: keyword (string), search_fields",
`"select_fields":["Name","Status"]`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
"search_fields length 1-20", "search_fields length 1-20",
"limit range 1-200 defaults to 10", "limit range 1-200 defaults to 10",
"view_id scopes search to records in that view", "view_id scopes search to records in that view",
"Default output is markdown", "Default output is markdown",
"only for keyword search", "only for keyword search",
"lark-base record read SOP", "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) { func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"} parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{}) BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
@@ -328,7 +741,7 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
tips := strings.Join(cmdutil.GetTips(cmd), "\n") tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{ 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"}]`, `"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
"full field-definition PUT semantics", "full field-definition PUT semantics",
"Read the current field first with +field-get", "Read the current field first with +field-get",

View File

@@ -22,6 +22,9 @@ var BaseDashboardArrange = common.Shortcut{
dashboardIDFlag(true), dashboardIDFlag(true),
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, {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, DryRun: dryRunDashboardArrange,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeDashboardArrange(runtime) return executeDashboardArrange(runtime)

View File

@@ -25,10 +25,19 @@ var BaseDashboardBlockCreate = common.Shortcut{
dashboardIDFlag(true), dashboardIDFlag(true),
{Name: "name", Desc: "block name", Required: 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: "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: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, {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"}, {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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime) pc := newParseCtx(runtime)
if runtime.Bool("no-validate") { if runtime.Bool("no-validate") {

View File

@@ -22,6 +22,10 @@ var BaseDashboardBlockDelete = common.Shortcut{
dashboardIDFlag(true), dashboardIDFlag(true),
blockIDFlag(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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI(). return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). 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), blockIDFlag(true),
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, {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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{} params := map[string]interface{}{}
if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" { if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" {

View File

@@ -23,6 +23,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
}, },
Tips: []string{ Tips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>", "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.", "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.", "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.", "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{ Flags: []common.Flag{
baseTokenFlag(true), baseTokenFlag(true),
dashboardIDFlag(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"}, {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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{} params := map[string]interface{}{}
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" { if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {

View File

@@ -24,10 +24,18 @@ var BaseDashboardBlockUpdate = common.Shortcut{
dashboardIDFlag(true), dashboardIDFlag(true),
blockIDFlag(true), blockIDFlag(true),
{Name: "name", Desc: "new block name"}, {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: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, {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"}, {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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime) pc := newParseCtx(runtime)
if runtime.Bool("no-validate") { if runtime.Bool("no-validate") {

View File

@@ -20,7 +20,10 @@ var BaseDashboardCreate = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
baseTokenFlag(true), baseTokenFlag(true),
{Name: "name", Desc: "dashboard name", Required: 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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{} body := map[string]interface{}{}

View File

@@ -21,6 +21,11 @@ var BaseDashboardDelete = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
dashboardIDFlag(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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI(). return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").

View File

@@ -21,6 +21,9 @@ var BaseDashboardGet = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
dashboardIDFlag(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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI(). return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").

View File

@@ -20,9 +20,12 @@ var BaseDashboardList = common.Shortcut{
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ Flags: []common.Flag{
baseTokenFlag(true), baseTokenFlag(true),
{Name: "page-size", Desc: "page size (max 100)"}, {Name: "page-size", Desc: "page size, max 100"},
{Name: "page-token", Desc: "pagination token"}, {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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{} params := map[string]interface{}{}
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" { if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {

View File

@@ -21,7 +21,7 @@ var BaseDashboardUpdate = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
dashboardIDFlag(true), dashboardIDFlag(true),
{Name: "name", Desc: "new dashboard name"}, {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 { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{} 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}, {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{ 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.", "Agent hint: use the lark-base skill's field-create guide for usage and limits.",
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -17,7 +17,11 @@ var BaseFieldDelete = common.Shortcut{
Scopes: []string{"base:field:delete"}, Scopes: []string{"base:field:delete"},
AuthTypes: authTypes(), AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, 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 { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFieldDelete(runtime) return executeFieldDelete(runtime)
}, },

View File

@@ -17,7 +17,12 @@ var BaseFieldGet = common.Shortcut{
Scopes: []string{"base:field:read"}, Scopes: []string{"base:field:read"},
AuthTypes: authTypes(), AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, 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 { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFieldGet(runtime) return executeFieldGet(runtime)
}, },

View File

@@ -20,7 +20,7 @@ var BaseFieldList = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {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, DryRun: dryRunFieldList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -22,7 +22,11 @@ var BaseFieldSearchOptions = common.Shortcut{
fieldRefFlag(true), fieldRefFlag(true),
{Name: "keyword", Desc: "keyword for option query"}, {Name: "keyword", Desc: "keyword for option query"},
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {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, DryRun: dryRunFieldSearchOptions,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { 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}, {Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
}, },
Tips: []string{ 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"}'`, baseHighRiskYesTip,
`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"}]}'`, `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.", "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.", "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.", "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" { if err != nil || obj["name"] != "demo" {
t.Fatalf("obj=%v err=%v", obj, err) 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) t.Fatalf("err=%v", err)
} }
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") { 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") { if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
t.Fatalf("err=%v", err) 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) t.Fatalf("err=%v", err)
} }
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) { if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
@@ -334,11 +334,11 @@ func TestJSONInputHelpers(t *testing.T) {
t.Fatalf("err=%v", err) t.Fatalf("err=%v", err)
} }
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7}) 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) t.Fatalf("syntaxErr=%v", syntaxErr)
} }
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"}) 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) 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{ Flags: []common.Flag{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
{Name: "json", Desc: "batch create JSON object", Required: true}, {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: []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.",
}, },
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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime) return validateRecordJSON(runtime)
}, },

View File

@@ -19,13 +19,14 @@ var BaseRecordBatchUpdate = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
{Name: "json", Desc: "batch update JSON object", Required: true}, {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: []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.",
}, },
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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime) return validateRecordJSON(runtime)
}, },

View File

@@ -22,6 +22,10 @@ var BaseRecordDelete = common.Shortcut{
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"}, {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"]}`}, {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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordSelection(runtime) return validateRecordSelection(runtime)
}, },

View File

@@ -21,7 +21,11 @@ var BaseRecordHistoryList = common.Shortcut{
tableRefFlag(true), tableRefFlag(true),
recordRefFlag(true), recordRefFlag(true),
{Name: "max-version", Type: "int", Desc: "max version for next page"}, {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, DryRun: dryRunRecordHistoryList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -15,6 +15,13 @@ import (
const maxRecordSelectionCount = 200 const maxRecordSelectionCount = 200
const maxBatchGetSelectFieldCount = 100 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 { type recordSelection struct {
recordIDs []string recordIDs []string
selectFields []string selectFields []string

View File

@@ -20,17 +20,15 @@ var BaseRecordSearch = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(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(), recordReadFormatFlag(),
}, },
Tips: []string{ 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}'`, `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 shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.", "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.", "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.", "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.", "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.",
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil { 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}, {Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true},
}, },
Tips: []string{ Tips: []string{
`Single record: --base-token xxx --table-id tblxxx --record-ids recxxx`, `Example: lark-cli base +record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>`,
`Multiple records: --base-token xxx --table-id tblxxx --record-ids rec001,rec002,rec003`, "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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordShareBatch(runtime) 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}, {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{ 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`, `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.`, `Repeat --file-token to remove multiple attachments from the same cell in one call.`,
`This is a high-risk write command and requires --yes.`, `This is a high-risk write command and requires --yes.`,

View File

@@ -20,12 +20,14 @@ var BaseRecordUpsert = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
recordRefFlag(false), recordRefFlag(false),
{Name: "json", Desc: "record JSON object: Map<FieldNameOrID, CellValue>", Required: true}, {Name: "json", Desc: `record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`, Required: true},
},
Tips: []string{
`Example: --json '{"Name":"Alice"}'`,
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
}, },
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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime) return validateRecordJSON(runtime)
}, },

View File

@@ -20,7 +20,11 @@ var BaseTableCreate = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
{Name: "name", Desc: "table name", Required: true}, {Name: "name", Desc: "table name", Required: true},
{Name: "view", Desc: "view JSON object/array for create"}, {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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateTableCreate(runtime) return validateTableCreate(runtime)

View File

@@ -17,7 +17,12 @@ var BaseTableDelete = common.Shortcut{
Scopes: []string{"base:table:delete"}, Scopes: []string{"base:table:delete"},
AuthTypes: authTypes(), AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, 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 { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeTableDelete(runtime) return executeTableDelete(runtime)
}, },

View File

@@ -17,7 +17,11 @@ var BaseTableGet = common.Shortcut{
Scopes: []string{"base:table:read", "base:field:read", "base:view:read"}, Scopes: []string{"base:table:read", "base:field:read", "base:view:read"},
AuthTypes: authTypes(), AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, 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 { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeTableGet(runtime) return executeTableGet(runtime)
}, },

View File

@@ -19,7 +19,7 @@ var BaseTableList = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
baseTokenFlag(true), baseTokenFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {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, DryRun: dryRunTableList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -19,11 +19,13 @@ var BaseViewCreate = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(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{ Tips: []string{
`Example: --json '{"name":"Main","type":"grid"}'`, `Example: lark-cli base +view-create --base-token <base_token> --table-id <table_id> --json '{"name":"Main","type":"grid"}'`,
"Agent hint: use the lark-base skill's view-create guide for usage and limits.", `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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewCreate(runtime) return validateViewCreate(runtime)

View File

@@ -17,7 +17,11 @@ var BaseViewDelete = common.Shortcut{
Scopes: []string{"base:view:write_only"}, Scopes: []string{"base:view:write_only"},
AuthTypes: authTypes(), AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, 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 { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeViewDelete(runtime) return executeViewDelete(runtime)
}, },

View File

@@ -20,7 +20,7 @@ var BaseViewList = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {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, DryRun: dryRunViewList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -20,11 +20,12 @@ var BaseViewSetCard = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
viewRefFlag(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{ Tips: []string{
`Example: --json '{"cover_field":"fldCover"}'`, "Supported view types: gallery, kanban.",
"Agent hint: use the lark-base skill's view-set-card guide for usage and limits.", "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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime) return validateViewJSONObject(runtime)

View File

@@ -20,10 +20,9 @@ var BaseViewSetFilter = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
viewRefFlag(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{ 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.", "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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -20,11 +20,13 @@ var BaseViewSetGroup = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
viewRefFlag(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{ Tips: []string{
`Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`, "Supported view types: grid, kanban, gantt.",
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.", "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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime) return validateViewJSONObject(runtime)

View File

@@ -20,11 +20,13 @@ var BaseViewSetSort = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
viewRefFlag(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{ Tips: []string{
`Example: --json '{"sort_config":[{"field":"fldPriority","desc":true}]}'`, "Supported view types: grid, kanban, gallery, gantt.",
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.", "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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime) return validateViewJSONObject(runtime)

View File

@@ -20,11 +20,12 @@ var BaseViewSetTimebar = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
viewRefFlag(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{ Tips: []string{
`Example: --json '{"start_time":"fldStart","end_time":"fldEnd","title":"fldTitle"}'`, "Supported view types: calendar, gantt.",
"Agent hint: use the lark-base skill's view-set-timebar guide for usage and limits.", "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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime) return validateViewJSONObject(runtime)

View File

@@ -20,11 +20,12 @@ var BaseViewSetVisibleFields = common.Shortcut{
baseTokenFlag(true), baseTokenFlag(true),
tableRefFlag(true), tableRefFlag(true),
viewRefFlag(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{ Tips: []string{
`Example: --json '{"visible_fields":["fldXXX"]}'`, "Supported view types: grid, kanban, gallery, calendar, gantt.",
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.", "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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime) return validateViewJSONObject(runtime)

View File

@@ -19,7 +19,15 @@ var BaseWorkflowCreate = common.Shortcut{
AuthTypes: []string{"user", "bot"}, AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true}, {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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { 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: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", 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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank") 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: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", 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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")

View File

@@ -20,7 +20,13 @@ var BaseWorkflowGet = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true}, {Name: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", 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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -20,7 +20,11 @@ var BaseWorkflowList = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true}, {Name: "base-token", Desc: "base token", Required: true},
{Name: "status", Desc: "filter by status", Enum: []string{"enabled", "disabled"}}, {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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -20,7 +20,16 @@ var BaseWorkflowUpdate = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "base-token", Desc: "base token", Required: true}, {Name: "base-token", Desc: "base token", Required: true},
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", 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 { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {

View File

@@ -29,7 +29,6 @@ import (
"github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
// RuntimeContext provides helpers for shortcut execution. // RuntimeContext provides helpers for shortcut execution.
@@ -72,16 +71,6 @@ func (ctx *RuntimeContext) IsBot() bool {
return ctx.As().IsBot() return ctx.As().IsBot()
} }
// Command returns the shortcut command name as cobra knows it (e.g.
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
// validation) that key off the shortcut identity.
func (ctx *RuntimeContext) Command() string {
if ctx.Cmd == nil {
return ""
}
return ctx.Cmd.Name()
}
// UserOpenId returns the current user's open_id from config. // UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId } func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
@@ -210,18 +199,6 @@ func (ctx *RuntimeContext) Int(name string) int {
return v 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)
return v
}
// StrArray returns a string-array flag value (repeated flag, no CSV splitting). // StrArray returns a string-array flag value (repeated flag, no CSV splitting).
func (ctx *RuntimeContext) StrArray(name string) []string { func (ctx *RuntimeContext) StrArray(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringArray(name) v, _ := ctx.Cmd.Flags().GetStringArray(name)
@@ -771,22 +748,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
return runShortcut(cmd, f, &shortcut, botOnly) 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.
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
if want, _ := c.Flags().GetBool("print-schema"); !want {
return nil
}
c.Flags().VisitAll(func(fl *pflag.Flag) {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
})
return nil
}
}
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes) cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut) registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips) cmdutil.SetTips(cmd, shortcut.Tips)
@@ -800,24 +761,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
// runShortcut is the execution pipeline for a declarative shortcut. // runShortcut is the execution pipeline for a declarative shortcut.
// Each step is a clear phase: identity → config → scopes → context → validate → execute. // Each step is a clear phase: identity → config → scopes → context → validate → execute.
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error { func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
// --print-schema short-circuits everything below: it's pure local
// introspection, no identity / scope / network needed. The flag is
// only registered when the shortcut opts in via PrintFlagSchema.
if s.PrintFlagSchema != nil {
if want, _ := cmd.Flags().GetBool("print-schema"); want {
flagName, _ := cmd.Flags().GetString("flag-name")
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
if err != nil {
return err
}
if len(out) == 0 {
return nil
}
fmt.Fprintln(f.IOStreams.Out, string(out))
return nil
}
}
as, err := resolveShortcutIdentity(cmd, f, s) as, err := resolveShortcutIdentity(cmd, f, s)
if err != nil { if err != nil {
return err return err
@@ -917,23 +860,11 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
} }
rctx.larkSDK = sdk rctx.larkSDK = sdk
if s.HasFormat { rctx.Format = rctx.Str("format")
rctx.Format = rctx.Str("format")
}
rctx.JqExpr, _ = cmd.Flags().GetString("jq") rctx.JqExpr, _ = cmd.Flags().GetString("jq")
return rctx, nil return rctx, nil
} }
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'".
// Some editors and exporters add it silently. Only a leading BOM is removed; interior
// occurrences are left untouched.
func stripUTF8BOM(s string) string {
return strings.TrimPrefix(s, "\uFEFF")
}
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources. // resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content. // Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
@@ -963,9 +894,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if err != nil { if err != nil {
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err) return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
} }
// strip a leading UTF-8 BOM so it can't corrupt the first CSV rctx.Cmd.Flags().Set(fl.Name, string(data))
// cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
continue continue
} }
@@ -988,9 +917,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if err != nil { if err != nil {
return FlagErrorf("--%s: %v", fl.Name, err) return FlagErrorf("--%s: %v", fl.Name, err)
} }
// strip a leading UTF-8 BOM so it rctx.Cmd.Flags().Set(fl.Name, string(data))
// can't corrupt the first CSV cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
continue continue
} }
} }
@@ -1075,14 +1002,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
var d int var d int
fmt.Sscanf(fl.Default, "%d", &d) fmt.Sscanf(fl.Default, "%d", &d)
cmd.Flags().Int(fl.Name, d, desc) 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)
cmd.Flags().Float64(fl.Name, d, desc)
case "string_array": case "string_array":
cmd.Flags().StringArray(fl.Name, nil, desc) cmd.Flags().StringArray(fl.Name, nil, desc)
case "string_slice": case "string_slice":
@@ -1105,21 +1024,15 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
} }
cmd.Flags().Bool("dry-run", false, "print request without executing") 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") cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
}
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
if s.PrintFlagSchema != nil {
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
}
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) { cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
}) })
} }
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
} }

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