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
199 changed files with 3303 additions and 6065 deletions

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

@@ -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

@@ -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) 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) 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,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,88 +34,11 @@ 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")
@@ -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 {
@@ -180,10 +77,11 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
} }
} }
// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings. // TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY 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")
@@ -201,6 +99,7 @@ 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")
@@ -257,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)
@@ -284,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) {
@@ -331,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
@@ -359,6 +237,7 @@ 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")

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,180 +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.
func WarnIfProxied(w io.Writer) {
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

@@ -860,9 +860,7 @@ 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
} }
@@ -1026,17 +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")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
} }
if s.Risk == "high-risk-write" { if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation") cmd.Flags().Bool("yes", false, "confirm high-risk operation")
} }
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output") cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes) cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
if s.HasFormat {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
}
} }

View File

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

View File

@@ -49,7 +49,7 @@ type Shortcut struct {
// Declarative fields (new framework). // Declarative fields (new framework).
AuthTypes []string // supported identities: "user", "bot" (default: ["user"]) AuthTypes []string // supported identities: "user", "bot" (default: ["user"])
Flags []Flag // flag definitions; --dry-run is auto-injected Flags []Flag // flag definitions; --dry-run is auto-injected
HasFormat bool // auto-inject --format flag (json|pretty|table|ndjson|csv) HasFormat bool // Deprecated: --format is now always injected; this field has no effect.
Tips []string // optional tips shown in --help output Tips []string // optional tips shown in --help output
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement

View File

@@ -102,9 +102,6 @@ func TestResolveMarkdownAsPost(t *testing.T) {
if !strings.Contains(got, `"tag":"md"`) { if !strings.Contains(got, `"tag":"md"`) {
t.Fatalf("resolveMarkdownAsPost() = %q, want post payload", got) t.Fatalf("resolveMarkdownAsPost() = %q, want post payload", got)
} }
if !strings.Contains(got, `"tag":"text"`) {
t.Fatalf("resolveMarkdownAsPost() = %q, want segmented blank-line text paragraph", got)
}
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) { if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got) t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
} }

View File

@@ -817,49 +817,25 @@ func readMp4Duration(f fileio.File, fileSize int64) int64 {
// 5. Compress excess blank lines // 5. Compress excess blank lines
// 6. Strip invalid image references (keep only img_xxx keys) // 6. Strip invalid image references (keep only img_xxx keys)
var ( var (
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`) reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
reH1 = regexp.MustCompile(`(?m)^# (.+)$`) reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `) reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`) reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`) reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`) reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
reExcessNL = regexp.MustCompile(`\n{3,}`) reExcessNL = regexp.MustCompile(`\n{3,}`)
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`) reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```") reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
reBlankLineSeparator = regexp.MustCompile(`\n(?:[ \t]*\n)+`)
) )
const (
markdownCodeBlockPlaceholder = "___CB_"
postBlankLinePlaceholder = "\u200B"
)
type markdownPart struct {
text string
newlineCount int
isSeparator bool
}
func protectMarkdownCodeBlocks(text string) (string, []string) {
var codeBlocks []string
protected := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
idx := len(codeBlocks)
codeBlocks = append(codeBlocks, m)
return fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx)
})
return protected, codeBlocks
}
func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string {
restored := text
for i, block := range codeBlocks {
restored = strings.Replace(restored, fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, i), block, 1)
}
return restored
}
func optimizeMarkdownStyle(text string) string { func optimizeMarkdownStyle(text string) string {
r, codeBlocks := protectMarkdownCodeBlocks(text) const mark = "___CB_"
var codeBlocks []string
r := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
idx := len(codeBlocks)
codeBlocks = append(codeBlocks, m)
return fmt.Sprintf("%s%d___", mark, idx)
})
// Only downgrade when original text has H1~H3; order matters (H2~H6 first). // Only downgrade when original text has H1~H3; order matters (H2~H6 first).
if reHasH1toH3.MatchString(text) { if reHasH1toH3.MatchString(text) {
@@ -872,7 +848,9 @@ func optimizeMarkdownStyle(text string) string {
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2") r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
r = reTableAfter.ReplaceAllString(r, "$1\n") r = reTableAfter.ReplaceAllString(r, "$1\n")
r = restoreMarkdownCodeBlocks(r, codeBlocks) for i, block := range codeBlocks {
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1)
}
r = reExcessNL.ReplaceAllString(r, "\n\n") r = reExcessNL.ReplaceAllString(r, "\n\n")
@@ -891,109 +869,12 @@ func optimizeMarkdownStyle(text string) string {
return r return r
} }
func shouldUseSegmentedPost(markdown string) bool {
protected, _ := protectMarkdownCodeBlocks(markdown)
return reBlankLineSeparator.MatchString(protected)
}
func splitMarkdownByBlankLines(markdown string) []markdownPart {
protected, codeBlocks := protectMarkdownCodeBlocks(markdown)
locs := reBlankLineSeparator.FindAllStringIndex(protected, -1)
if len(locs) == 0 {
return []markdownPart{{text: markdown}}
}
parts := make([]markdownPart, 0, len(locs)*2+1)
last := 0
for _, loc := range locs {
if loc[0] > last {
content := restoreMarkdownCodeBlocks(protected[last:loc[0]], codeBlocks)
if content != "" {
parts = append(parts, markdownPart{text: content})
}
}
separator := protected[loc[0]:loc[1]]
parts = append(parts, markdownPart{
isSeparator: true,
newlineCount: strings.Count(separator, "\n"),
})
last = loc[1]
}
if last < len(protected) {
content := restoreMarkdownCodeBlocks(protected[last:], codeBlocks)
if content != "" {
parts = append(parts, markdownPart{text: content})
}
}
if len(parts) == 0 {
return []markdownPart{{text: markdown}}
}
return parts
}
func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
payload := map[string]interface{}{
"zh_cn": map[string]interface{}{
"content": content,
},
}
data, _ := json.Marshal(payload)
return string(data)
}
func buildSingleMDPost(markdown string) string {
return marshalMarkdownPostContent([][]map[string]interface{}{
{{
"tag": "md",
"text": optimizeMarkdownStyle(markdown),
}},
})
}
func buildSegmentedPost(markdown string) string {
parts := splitMarkdownByBlankLines(markdown)
content := make([][]map[string]interface{}, 0, len(parts))
for _, part := range parts {
if part.isSeparator {
for i := 1; i < part.newlineCount; i++ {
content = append(content, []map[string]interface{}{{
"tag": "text",
"text": postBlankLinePlaceholder,
}})
}
continue
}
if part.text == "" {
continue
}
optimized := strings.Trim(optimizeMarkdownStyle(part.text), "\n")
if optimized == "" {
continue
}
content = append(content, []map[string]interface{}{{
"tag": "md",
"text": optimized,
}})
}
if len(content) == 0 {
return buildSingleMDPost(markdown)
}
return marshalMarkdownPostContent(content)
}
func buildMarkdownPostContent(markdown string) string {
if shouldUseSegmentedPost(markdown) {
return buildSegmentedPost(markdown)
}
return buildSingleMDPost(markdown)
}
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network). // wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present. // Used by DryRun. Output: {"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
func wrapMarkdownAsPost(markdown string) string { func wrapMarkdownAsPost(markdown string) string {
return buildMarkdownPostContent(markdown) optimized := optimizeMarkdownStyle(markdown)
inner, _ := json.Marshal(optimized)
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
} }
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`) var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`)
@@ -1028,7 +909,9 @@ func wrapMarkdownAsPostForDryRun(markdown string) (content, desc string) {
// and wraps as post format JSON. Used by Execute (makes network calls). // and wraps as post format JSON. Used by Execute (makes network calls).
func resolveMarkdownAsPost(ctx context.Context, runtime *common.RuntimeContext, markdown string) string { func resolveMarkdownAsPost(ctx context.Context, runtime *common.RuntimeContext, markdown string) string {
resolved := resolveMarkdownImageURLs(ctx, runtime, markdown) resolved := resolveMarkdownImageURLs(ctx, runtime, markdown)
return buildMarkdownPostContent(resolved) optimized := optimizeMarkdownStyle(resolved)
inner, _ := json.Marshal(optimized)
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
} }
// resolveMarkdownImageURLs finds ![alt](https://...) in markdown, downloads each URL, // resolveMarkdownImageURLs finds ![alt](https://...) in markdown, downloads each URL,

View File

@@ -6,7 +6,6 @@ package im
import ( import (
"context" "context"
"encoding/binary" "encoding/binary"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -17,36 +16,6 @@ import (
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
func decodePostContentForTest(t *testing.T, raw string) []interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v, raw=%s", err, raw)
}
locale, _ := payload["zh_cn"].(map[string]interface{})
content, _ := locale["content"].([]interface{})
if content == nil {
t.Fatalf("post content missing: %#v", payload)
}
return content
}
func decodePostParagraphForTest(t *testing.T, raw string, idx int) map[string]interface{} {
t.Helper()
content := decodePostContentForTest(t, raw)
if idx >= len(content) {
t.Fatalf("paragraph index %d out of range, len=%d, raw=%s", idx, len(content), raw)
}
paragraph, _ := content[idx].([]interface{})
if len(paragraph) != 1 {
t.Fatalf("paragraph %d = %#v, want single node", idx, paragraph)
}
node, _ := paragraph[0].(map[string]interface{})
return node
}
func TestNormalizeAtMentions(t *testing.T) { func TestNormalizeAtMentions(t *testing.T) {
input := `<at id=ou_alpha/> hi <at open_id="ou_beta"> and <at user_id=ou_gamma /> and <at email="x@example.com"/>` input := `<at id=ou_alpha/> hi <at open_id="ou_beta"> and <at user_id=ou_gamma /> and <at email="x@example.com"/>`
got := normalizeAtMentions(input) got := normalizeAtMentions(input)
@@ -171,16 +140,6 @@ func TestWrapMarkdownAsPostForDryRun(t *testing.T) {
} }
} }
func TestWrapMarkdownAsPostForDryRun_SegmentedBlankLines(t *testing.T) {
content, _ := wrapMarkdownAsPostForDryRun("hello\n\n![alt](https://example.com/a.png)")
if !strings.Contains(content, `![alt](img_dryrun_1)`) {
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want placeholder img key", content)
}
if !strings.Contains(content, `"tag":"text"`) {
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want blank-line text paragraph", content)
}
}
func TestResolveMediaContentWithoutUploads(t *testing.T) { func TestResolveMediaContentWithoutUploads(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -375,88 +334,15 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
func TestWrapMarkdownAsPost(t *testing.T) { func TestWrapMarkdownAsPost(t *testing.T) {
got := wrapMarkdownAsPost("hello **world**") got := wrapMarkdownAsPost("hello **world**")
content := decodePostContentForTest(t, got) // Should produce valid JSON with post structure
if len(content) != 1 { if !strings.Contains(got, `"tag":"md"`) {
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content)) t.Fatalf("wrapMarkdownAsPost() missing md tag: %s", got)
} }
node := decodePostParagraphForTest(t, got, 0) if !strings.Contains(got, `"zh_cn"`) {
if node["tag"] != "md" { t.Fatalf("wrapMarkdownAsPost() missing zh_cn: %s", got)
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
} }
if node["text"] != "hello **world**" { if !strings.Contains(got, "hello **world**") {
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**") t.Fatalf("wrapMarkdownAsPost() missing content: %s", got)
}
}
func TestShouldUseSegmentedPost(t *testing.T) {
tests := []struct {
name string
markdown string
want bool
}{
{name: "single newline", markdown: "a\nb", want: false},
{name: "blank line", markdown: "a\n\nb", want: true},
{name: "blank line with spaces", markdown: "a\n \nb", want: true},
{name: "multiple blank lines", markdown: "a\n \n \n b", want: true},
{name: "blank lines inside code block only", markdown: "```go\n\n\nfmt.Println(1)\n```\nnext", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldUseSegmentedPost(tt.markdown); got != tt.want {
t.Fatalf("shouldUseSegmentedPost(%q) = %v, want %v", tt.markdown, got, tt.want)
}
})
}
}
func TestWrapMarkdownAsPost_SegmentedBlankLines(t *testing.T) {
got := wrapMarkdownAsPost("a\n\nb")
content := decodePostContentForTest(t, got)
if len(content) != 3 {
t.Fatalf("wrapMarkdownAsPost(a\\n\\nb) content len = %d, want 3", len(content))
}
first := decodePostParagraphForTest(t, got, 0)
if first["tag"] != "md" || first["text"] != "a" {
t.Fatalf("first paragraph = %#v, want md/a", first)
}
second := decodePostParagraphForTest(t, got, 1)
if second["tag"] != "text" || second["text"] != postBlankLinePlaceholder {
t.Fatalf("second paragraph = %#v, want blank text placeholder", second)
}
third := decodePostParagraphForTest(t, got, 2)
if third["tag"] != "md" || third["text"] != "b" {
t.Fatalf("third paragraph = %#v, want md/b", third)
}
}
func TestWrapMarkdownAsPost_SegmentedMultipleBlankLines(t *testing.T) {
got := wrapMarkdownAsPost("a\n\n\nb")
content := decodePostContentForTest(t, got)
if len(content) != 4 {
t.Fatalf("wrapMarkdownAsPost(a\\n\\n\\nb) content len = %d, want 4", len(content))
}
for i := 1; i <= 2; i++ {
node := decodePostParagraphForTest(t, got, i)
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
t.Fatalf("blank paragraph %d = %#v, want blank text placeholder", i, node)
}
}
}
func TestWrapMarkdownAsPost_SegmentedBlankLinesWithSpaces(t *testing.T) {
got := wrapMarkdownAsPost("a\n \nb")
content := decodePostContentForTest(t, got)
if len(content) != 3 {
t.Fatalf("wrapMarkdownAsPost(a\\n \\nb) content len = %d, want 3", len(content))
}
node := decodePostParagraphForTest(t, got, 1)
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
t.Fatalf("middle paragraph = %#v, want blank text placeholder", node)
} }
} }

View File

@@ -161,6 +161,11 @@ func TestValidateProxyAddr(t *testing.T) {
"http://gateway.docker.internal:16384", "http://gateway.docker.internal:16384",
// trailing slash is tolerated // trailing slash is tolerated
"http://127.0.0.1:8080/", "http://127.0.0.1:8080/",
// https: any valid host (including remote, cross-machine) is allowed
"https://127.0.0.1:16384",
"https://sidecar.mycorp.com",
"https://sidecar.mycorp.com:8443",
"https://sidecar.corp.internal:443/",
} }
for _, addr := range valid { for _, addr := range valid {
if err := ValidateProxyAddr(addr); err != nil { if err := ValidateProxyAddr(addr); err != nil {
@@ -242,6 +247,8 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
"http://user@127.0.0.1:16384", "http://user@127.0.0.1:16384",
"http://user:pass@127.0.0.1:16384", "http://user:pass@127.0.0.1:16384",
"http://127.0.0.1@attacker.com:16384", "http://127.0.0.1@attacker.com:16384",
"https://x@evil.com",
"https://user:pass@sidecar.mycorp.com",
} { } {
err := ValidateProxyAddr(addr) err := ValidateProxyAddr(addr)
if err == nil { if err == nil {
@@ -259,23 +266,99 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
} }
} }
// TestValidateProxyAddr_HTTPSRejected pins the current contract: https is // TestValidateProxyAddr_HTTPSAllowed pins the contract: https addresses are
// rejected explicitly (not lumped into a generic "bad scheme" error) because // accepted, including a remote sidecar on another machine. TLS provides
// the interceptor hardcodes http and would silently downgrade an https URL // confidentiality over the network and the HMAC signature provides
// otherwise. The message must mention https so users understand why their // integrity/auth, so cross-machine https is supported.
// perfectly-looking config is refused. func TestValidateProxyAddr_HTTPSAllowed(t *testing.T) {
func TestValidateProxyAddr_HTTPSRejected(t *testing.T) {
for _, addr := range []string{ for _, addr := range []string{
"https://127.0.0.1:16384", "https://127.0.0.1:16384", // same-host over TLS
"https://sidecar.corp.internal:443", "https://sidecar.corp.internal:443",
"https://sidecar.mycorp.com", // remote, no explicit port
"https://sidecar.mycorp.com:8443",
} {
if err := ValidateProxyAddr(addr); err != nil {
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
}
}
}
// TestValidateProxyAddr_HTTPRemoteRejected: plaintext http to a non-same-host
// address stays rejected — a remote sidecar must use https.
func TestValidateProxyAddr_HTTPRemoteRejected(t *testing.T) {
for _, addr := range []string{
"http://sidecar.mycorp.com",
"http://sidecar.mycorp.com:8080",
"http://10.0.0.1:16384",
} { } {
err := ValidateProxyAddr(addr) err := ValidateProxyAddr(addr)
if err == nil { if err == nil {
t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr) t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
continue continue
} }
if !strings.Contains(err.Error(), "https") { msg := err.Error()
t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err) if !strings.Contains(msg, "https") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") {
t.Errorf("ValidateProxyAddr(%q): error should point to https/same-host, got: %v", addr, err)
}
}
}
// TestProxyScheme: scheme is https only for https:// addresses, http otherwise.
// Case-insensitive: HTTPS:// must resolve to https, otherwise a remote sidecar
// would silently downgrade to plaintext http (see ProxyScheme doc).
func TestProxyScheme(t *testing.T) {
tests := map[string]string{
"https://sidecar.mycorp.com": "https",
"https://127.0.0.1:16384": "https",
"http://127.0.0.1:16384": "http",
"127.0.0.1:16384": "http",
// case-insensitive scheme
"HTTPS://sidecar.mycorp.com": "https",
"Https://sidecar.mycorp.com": "https",
"HtTp://127.0.0.1:16384": "http",
}
for in, want := range tests {
if got := ProxyScheme(in); got != want {
t.Errorf("ProxyScheme(%q) = %q, want %q", in, got, want)
}
}
}
// TestValidateProxyAddr_SchemeCaseInsensitive: mixed-case scheme must follow the
// same policy as lower-case — HTTPS accepted (remote allowed), HTTP remote
// rejected — so case can't be used to bypass the plaintext same-host rule.
func TestValidateProxyAddr_SchemeCaseInsensitive(t *testing.T) {
for _, addr := range []string{"HTTPS://sidecar.mycorp.com", "Https://sidecar.corp.internal:443"} {
if err := ValidateProxyAddr(addr); err != nil {
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
}
}
for _, addr := range []string{"HtTp://sidecar.mycorp.com", "HTTP://10.0.0.1:16384"} {
if err := ValidateProxyAddr(addr); err == nil {
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
}
}
}
// TestValidateProxyAddr_IPv6HTTPS pins IPv6 https forms.
func TestValidateProxyAddr_IPv6HTTPS(t *testing.T) {
for _, addr := range []string{"https://[::1]:443", "https://[::1]"} {
if err := ValidateProxyAddr(addr); err != nil {
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
}
}
}
// TestValidateProxyAddr_RejectsQueryFragment: a proxy address must not carry a
// query or fragment, for either scheme.
func TestValidateProxyAddr_RejectsQueryFragment(t *testing.T) {
for _, addr := range []string{
"https://sidecar.mycorp.com?x=1",
"https://sidecar.mycorp.com#frag",
"http://127.0.0.1:16384?x=1",
} {
if err := ValidateProxyAddr(addr); err == nil {
t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr)
} }
} }
} }
@@ -289,6 +372,10 @@ func TestProxyHost(t *testing.T) {
{"http://0.0.0.0:8080", "0.0.0.0:8080"}, {"http://0.0.0.0:8080", "0.0.0.0:8080"},
{"http://host.docker.internal:16384/", "host.docker.internal:16384"}, {"http://host.docker.internal:16384/", "host.docker.internal:16384"},
{"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme {"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme
// https forms (remote sidecar)
{"https://sidecar.mycorp.com", "sidecar.mycorp.com"},
{"https://sidecar.mycorp.com:8443/", "sidecar.mycorp.com:8443"},
{"HTTPS://sidecar.mycorp.com", "sidecar.mycorp.com"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) { t.Run(tt.input, func(t *testing.T) {

View File

@@ -3,7 +3,8 @@
// Package sidecar defines the wire protocol shared between the CLI client // Package sidecar defines the wire protocol shared between the CLI client
// (running inside a sandbox) and the auth sidecar proxy (running in a // (running inside a sandbox) and the auth sidecar proxy (running in a
// trusted environment). Communication uses plain HTTP. // trusted environment). Communication uses HTTP for a same-host sidecar, or
// HTTPS (TLS) for a remote sidecar.
package sidecar package sidecar
import ( import (
@@ -103,32 +104,31 @@ func isSameHost(host string) bool {
return false return false
} }
// errNotSameHost is the shared error returned when the sidecar address does // errNotSameHost is the shared error returned when a plaintext (http) sidecar
// not resolve to the same physical host as the sandbox. Kept in one place so // address does not resolve to the same physical host as the sandbox. Kept in
// tests can look for a stable marker. // one place so tests can look for a stable marker.
func errNotSameHost(addr string) error { func errNotSameHost(addr string) error {
return fmt.Errorf("invalid proxy address %q: host must be loopback "+ return fmt.Errorf("invalid proxy address %q: a plaintext (http) sidecar must be "+
"(127.0.0.1 / ::1) or a recognized same-host alias "+ "loopback (127.0.0.1 / ::1) or a recognized same-host alias "+
"(localhost, host.docker.internal, host.containers.internal, "+ "(localhost, host.docker.internal, host.containers.internal, "+
"host.lima.internal, gateway.docker.internal). "+ "host.lima.internal, gateway.docker.internal). "+
"The sidecar must run on the same physical machine as the sandbox — "+ "For a remote sidecar on another machine, use an https:// address instead", addr)
"cross-machine deployment is not a sidecar and is not supported", addr)
} }
// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value. // ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value.
// Accepted formats: // Accepted formats:
// - http://host:port // - https://host[:port] (remote sidecar; cross-machine allowed)
// - host:port (bare address, treated as http) // - http://host:port (plaintext; same-host only)
// - host:port (bare address, treated as plaintext http; same-host only)
// //
// Host must be loopback or in sameHostAliases. The sidecar pattern is // Scheme policy:
// inherently same-machine; cross-machine deployment is a different product // - https:// — any valid host is allowed, including a remote central sidecar
// and is not supported by this feature. // on another machine. TLS provides confidentiality over the untrusted
// // network; the per-request HMAC signature provides integrity/auth.
// https:// is rejected because sidecar is a same-host pattern: loopback // - http:// (or bare host:port) — plaintext, allowed only when the host is
// and virtual same-host bridges don't traverse any untrusted medium, so // loopback (127.0.0.1 / ::1) or a recognized same-host alias (a virtual
// TLS adds no security. Cross-machine deployment is out of scope (see the // same-host bridge that stays on the physical machine). For a remote
// host constraint above), so there is no scenario today where https // sidecar, use an https:// address instead.
// provides a real benefit over http on loopback.
// //
// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol // userinfo (user:pass@) is rejected unconditionally — the sidecar protocol
// does not use basic auth, and the syntactic slot exists only as a phishing // does not use basic auth, and the syntactic slot exists only as a phishing
@@ -140,11 +140,11 @@ func ValidateProxyAddr(addr string) error {
return fmt.Errorf("proxy address is empty") return fmt.Errorf("proxy address is empty")
} }
// Bare host:port (no scheme) — validate as a net address. // Bare host:port (no scheme) — treated as plaintext http, so same-host only.
if !strings.Contains(addr, "://") { if !strings.Contains(addr, "://") {
host, port, err := net.SplitHostPort(addr) host, port, err := net.SplitHostPort(addr)
if err != nil { if err != nil {
return fmt.Errorf("invalid proxy address %q: expected host:port or http://host:port", addr) return fmt.Errorf("invalid proxy address %q: expected host:port or http(s)://host[:port]", addr)
} }
if host == "" || port == "" { if host == "" || port == "" {
return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr) return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr)
@@ -159,33 +159,47 @@ func ValidateProxyAddr(addr string) error {
if err != nil { if err != nil {
return fmt.Errorf("invalid proxy address %q: %w", addr, err) return fmt.Errorf("invalid proxy address %q: %w", addr, err)
} }
// userinfo (user:pass@) is rejected unconditionally (phishing vector).
if u.User != nil { if u.User != nil {
return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr) return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr)
} }
if u.Scheme == "https" {
return fmt.Errorf("invalid proxy address %q: use http:// — sidecar is "+
"same-host only (loopback or virtual same-host bridge), so TLS adds "+
"no security; cross-machine deployment is out of scope", addr)
}
if u.Scheme != "http" {
return fmt.Errorf("invalid proxy address %q: scheme must be http", addr)
}
if u.Host == "" { if u.Host == "" {
return fmt.Errorf("invalid proxy address %q: missing host", addr) return fmt.Errorf("invalid proxy address %q: missing host", addr)
} }
if u.Path != "" && u.Path != "/" { if u.Path != "" && u.Path != "/" {
return fmt.Errorf("invalid proxy address %q: path is not allowed", addr) return fmt.Errorf("invalid proxy address %q: path is not allowed", addr)
} }
// u.Hostname() strips the port and unwraps IPv6 brackets. if u.RawQuery != "" {
if !isSameHost(u.Hostname()) { return fmt.Errorf("invalid proxy address %q: query is not allowed", addr)
return errNotSameHost(addr) }
if u.Fragment != "" {
return fmt.Errorf("invalid proxy address %q: fragment is not allowed", addr)
}
switch u.Scheme {
case "https":
// Remote sidecar over TLS. Cross-machine is allowed: https provides
// confidentiality over the network and the per-request HMAC signature
// provides integrity/authentication, so a remote central sidecar is
// supported without exposing credentials or signing material in clear.
return nil
case "http":
// Plaintext: only safe on the same physical host (loopback or a virtual
// same-host bridge). For a remote sidecar use an https:// address.
// u.Hostname() strips the port and unwraps IPv6 brackets.
if !isSameHost(u.Hostname()) {
return errNotSameHost(addr)
}
return nil
default:
return fmt.Errorf("invalid proxy address %q: scheme must be http or https", addr)
} }
return nil
} }
// ProxyHost extracts the host:port from an AUTH_PROXY URL. // ProxyHost extracts the host:port from an AUTH_PROXY URL.
// Input is expected to be an HTTP URL like "http://127.0.0.1:16384". // Input is expected to be an http:// or https:// URL like
// Returns the host:port portion for URL rewriting. // "http://127.0.0.1:16384" or "https://sidecar.mycorp.com".
// Returns the host[:port] portion for URL rewriting.
func ProxyHost(authProxy string) string { func ProxyHost(authProxy string) string {
// Strip scheme // Strip scheme
host := authProxy host := authProxy
@@ -196,3 +210,19 @@ func ProxyHost(authProxy string) string {
host = strings.TrimRight(host, "/") host = strings.TrimRight(host, "/")
return host return host
} }
// ProxyScheme returns the URL scheme the CLI must use when routing to the
// sidecar: "https" for a TLS (remote) sidecar, otherwise "http" (same-host
// plaintext). Input is a value already accepted by ValidateProxyAddr.
//
// It parses the address (rather than a case-sensitive prefix check) so the
// result stays consistent with ValidateProxyAddr, which relies on url.Parse
// normalizing the scheme. Otherwise "HTTPS://host" — accepted as https by
// ValidateProxyAddr — would silently downgrade to plaintext http here,
// breaking the "remote must use TLS" boundary.
func ProxyScheme(authProxy string) string {
if u, err := url.Parse(authProxy); err == nil && strings.EqualFold(u.Scheme, "https") {
return "https"
}
return "http"
}

View File

@@ -114,18 +114,23 @@ export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to o
**`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup: **`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup:
- Scheme must be `http://` (or bare `host:port`). `https://` is rejected - Scheme must be `http://` / `https://` (or bare `host:port`, treated as
today because the interceptor does not yet perform TLS; a future PR that plaintext http).
wires up real TLS will relax this. - `https://<any-host>` is allowed, **including a remote sidecar on another
- Host must be loopback (`127.0.0.1`, `::1`) or one of the recognized machine**: TLS provides confidentiality over the network and the
same-host aliases: `localhost`, `host.docker.internal`, per-request HMAC signature provides integrity/authentication.
`host.containers.internal`, `host.lima.internal`, `gateway.docker.internal`. - Plaintext `http://` (and bare `host:port`) is allowed **only same-host**:
The sidecar pattern is inherently same-machine; cross-machine deployment loopback (`127.0.0.1`, `::1`) or a recognized same-host alias
is a different product (auth broker / STS) with different security (`localhost`, `host.docker.internal`, `host.containers.internal`,
requirements (mTLS, cert rotation, per-client keys) and is not supported `host.lima.internal`, `gateway.docker.internal`). For a remote sidecar,
by this feature. use an `https://` address.
- No path, query, fragment, or `user:pass@` in the URL. - No path, query, fragment, or `user:pass@` in the URL.
> Note: this demo server itself terminates plain HTTP and is meant to run
> locally. A production **remote** sidecar must terminate TLS (its own
> `https://` endpoint, e.g. behind a load balancer or with a real
> certificate); the CLI-side policy above is what enables pointing at it.
**How auto identity detection works in sidecar mode**: on every invocation the **How auto identity detection works in sidecar mode**: on every invocation the
CLI asks the sidecar to look up the logged-in user's `open_id` via CLI asks the sidecar to look up the logged-in user's `open_id` via
`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`; `/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`;

View File

@@ -8,7 +8,7 @@
- `lark-cli base records create` - `lark-cli base records create`
2. **优先使用 Shortcut** — 有 Shortcut 的操作不要手拼原生 API 2. **优先使用 Shortcut** — 有 Shortcut 的操作不要手拼原生 API
3. **写记录前** — 先调用 `table.fields list` 获取字段 `type/ui_type`,再读 [lark-base-cell-value.md](../../skills/lark-base/references/lark-base-cell-value.md);该文档是 CellValue 的 source of truth 3. **写记录前** — 先调用 `table.fields list` 获取字段 `type/ui_type`,再读 [lark-base-cell-value.md](../../skills/lark-base/references/lark-base-cell-value.md);该文档是 CellValue 的 source of truth
4. **写字段前** — 先读 [lark-base-shortcut-field-properties.md](../../skills/lark-base/references/lark-base-shortcut-field-properties.md) 确认字段类型 JSON 结构 4. **写字段前** — 先读 [lark-base-field-json.md](../../skills/lark-base/references/lark-base-field-json.md) 确认字段类型 JSON 结构
5. **筛选查询前** — 先读 [lark-base-view-set-filter.md](../../skills/lark-base/references/lark-base-view-set-filter.md),当前 `base/v3` 通过 `view.filter update + table.records list` 组合完成筛选读取 5. **筛选查询前** — 先读 [lark-base-view-set-filter.md](../../skills/lark-base/references/lark-base-view-set-filter.md),当前 `base/v3` 通过 `view.filter update + table.records list` 组合完成筛选读取
6. **批量上限 200 条/次** — 同一表建议串行写入,并在批次间延迟 0.51 秒 6. **批量上限 200 条/次** — 同一表建议串行写入,并在批次间延迟 0.51 秒
7. **改名和删除按明确意图执行** — 视图重命名这类低风险改名操作,目标和新名称明确时可直接执行;删除记录 / 字段 / 表时,只要用户已经明确要求删除且目标明确,也可直接执行,不需要再补一次确认 7. **改名和删除按明确意图执行** — 视图重命名这类低风险改名操作,目标和新名称明确时可直接执行;删除记录 / 字段 / 表时,只要用户已经明确要求删除且目标明确,也可直接执行,不需要再补一次确认
@@ -113,7 +113,7 @@ lark-cli wiki spaces.get_node --params '{"token":"Pgrrwvr***********UnRb"}'
## 参考文档 ## 参考文档
- [lark-base-shortcut-field-properties.md](../../skills/lark-base/references/lark-base-shortcut-field-properties.md) — 字段类型 JSON 配置 - [lark-base-field-json.md](../../skills/lark-base/references/lark-base-field-json.md) — 字段类型 JSON 配置
- [lark-base-cell-value.md](../../skills/lark-base/references/lark-base-cell-value.md) — CellValue source of truth - [lark-base-cell-value.md](../../skills/lark-base/references/lark-base-cell-value.md) — CellValue source of truth
- [lark-base-view-set-filter.md](../../skills/lark-base/references/lark-base-view-set-filter.md) — 查询筛选指南filter / operator / sort / 分页) - [lark-base-view-set-filter.md](../../skills/lark-base/references/lark-base-view-set-filter.md) — 查询筛选指南filter / operator / sort / 分页)
- [examples.md](../../skills/lark-base/references/examples.md) — 完整操作示例(建表、筛选、更新) - 具体命令示例由命令 --help 内置 tips 承接;复杂 JSON 只读上方保留 reference

View File

@@ -1,7 +1,7 @@
--- ---
name: lark-base name: lark-base
version: 1.2.2 version: 1.2.2
description: "当需要用 lark-cli 操作飞书多维表格Base时调用搜索 Base、建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。" description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive认证/授权转 lark-shared。"
metadata: metadata:
requires: requires:
bins: ["lark-cli"] bins: ["lark-cli"]
@@ -10,353 +10,151 @@ metadata:
# base # base
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。 ## 何时使用
> **执行前必做:** 执行任何 `base` 命令前,必须先阅读对应命令的 reference 文档,再调用命令。
> **查询类任务必做:** 涉及筛选、排序、Top/Bottom N、聚合、多表关联、查询后写入或判断全局结论时必须先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),再选择 `record / view / data-query` 路径。
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;解析 Wiki 链接使用 `lark-cli wiki +node-get`。
> **分流规则:** 如果用户要“把本地文件导入成 Base / 多维表格 / bitable”第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
## 1. 何时使用本 Skill 使用本 skill
### 1.1 触发条件 - 用户明确提到 Base / 多维表格 / bitable或给出 `/base/` 链接。
- 用户要在 Base 内建表、改表、管理字段、写记录、查记录、配视图。
- 用户要在 Base 内做公式字段、lookup 字段、跨表计算、派生指标、筛选聚合、TopN、统计分析。
- 用户要管理 Base 表单、仪表盘、workflow、高级权限或角色。
- 用户要把旧 Base 聚合式命令或旧写法迁移到当前 `lark-cli base +...` shortcut。
以下场景应使用本 skill 不要使用本 skill
- 用户明确要操作飞书多维表格 / Base - 只是认证、初始化配置、切换身份、处理 scope 或权限授权恢复,转 `lark-shared`
- 用户要建表、改表、查表、删表,或管理字段、记录、视图 - 把本地 Excel / CSV / `.base` 导入成 Base`lark-drive +import --type bitable`
- 用户要做公式字段、lookup 字段、派生指标、跨表计算 - 泛化数据分析、字段设计、公式讨论,但没有 Base/多维表格上下文
- 用户要做临时统计、聚合分析、比较排序、求最值。
- 用户要管理 workflow、dashboard、表单、角色权限。
- 用户给出 `/base/{token}` 链接。
- 用户给出 `/wiki/{token}` 链接,且最终解析为 `bitable`
- 用户要把旧的 Base 聚合式写法改成当前原子命令写法,例如把旧 `+table / +field / +record / +view / +history / +workspace` 改写成当前命令。
以下场景不应使用本 skill ## 使用边界
- 用户只是做认证、初始化配置、切换 `--as user/bot`、处理 scope。此时先读 `../lark-shared/SKILL.md` - Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 用户只是泛化地讨论“数据分析 / 字段设计”,但并不在 Base 场景中。不要因为提到“统计 / 公式 / lookup”就误触发 - 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL简单命令由命令自身的参数、tips 和错误恢复承接
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
- 用户只给 Base 名称或关键词时,先用 `lark-cli drive +search --query <keyword> --doc-types bitable` 定位资源。
- Base 命令必须先有 `base_token` 或可解析出的 Base URL。没有 token 时:用户要新建就用 `+base-create`;用户给标题/关键词就搜 `lark-cli drive +search --query "<base title>" --doc-types bitable --only-title --as user`;仍无法定位时,反问用户具体是哪一个 Base。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
### 1.2 前置约束 ## 快速路由
1. 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。 | 用户目标 | 优先命令 | 何时读 reference |
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令。 |---|---|---|
3. 如果输入是 Wiki 链接或 Wiki token并且用户想读取/操作其中的 Base先执行 `lark-cli wiki +node-get --node-token <wiki_url_or_token>`;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token。旧的 `--token` flag 仍可用,但已 deprecated会在 stderr 打印迁移提示。) | 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
4. 定位到命令后,先读该命令对应的 reference再执行命令。 | 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` |
5. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。 | 管理表 | `+table-list/get/create/update/delete` | `+table-create --fields` 复杂时读 `lark-base-field-json.md` |
6. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...` | 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID删除前确认目标字段 |
7. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli drive +search --query <keyword> --doc-types bitable` 搜索 Base / 多维表格资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-drive/references/lark-drive-search.md`](../lark-drive/references/lark-drive-search.md):标题精确匹配、限定 owner`--mine` / `--creator-ids`owner 语义非"最初创建人"/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索。 | 创建/更新字段 | `+field-create` / `+field-update` | 必读 `lark-base-field-json.md`;公式读 `formula-field-guide.md`lookup 读 `lookup-field-guide.md`;命令细节读 `lark-base-field-create.md` / `lark-base-field-update.md` |
| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 `lark-base-data-analysis-sop.md` |
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读对应 record reference 和 `lark-base-cell-value.md` |
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue上传走本地文件下载/删除按 file token 或字段定位 |
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record分享链接最多 100 条;历史读 `lark-base-record-history-list.md`,只查单条记录,不做整表审计 |
| 管理视图 | `+view-*` | `+view-set-filter``lark-base-view-set-filter.md`;其余配置先 get 现状,再按返回结构更新 |
| 一次性聚合统计 | `+data-query` | 必读 `lark-base-data-analysis-sop.md` 和入口 `lark-base-data-query-guide.md`;完整 DSL 再读 `lark-base-data-query.md` |
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 `formula-field-guide.md`,读后再加隐藏确认 flag `--i-have-read-guide` |
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 `lookup-field-guide.md`,读后再加隐藏确认 flag `--i-have-read-guide` |
| 表单提交 | `+form-submit` | 先读 `lark-base-form-detail.md` 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 `lark-base-form-submit.md` |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读对应 form-questions reference |
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail``lark-base-form-detail.md`;删除前确认目标表单 |
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 `lark-base-dashboard.md`;组件 `data_config``dashboard-block-data-config.md`;读取图表计算结果用 `+dashboard-block-get-data` |
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 `lark-base-workflow-guide.md` 和 steps JSON SSOT `lark-base-workflow-schema.md`list/get/enable/disable 只处理 workflow ID 与启停状态 |
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 `lark-base-role-guide.md`;角色 create/update 或解读完整配置再读权限 JSON SSOT `role-config.md`;系统角色不可删除;关闭高级权限会影响自定义角色 |
## 2. 模块与命令导航 ## Base 心智模型
本章按“先选模块,再选命令”的方式组织。先判断用户目标属于哪个大模块,再进入对应子模块,按要求阅读 reference 后执行命令。 - Base 曾用名 Bitable返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。
- 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。
- 存储字段可写;系统字段、`formula``lookup` 只读;附件字段走专用 attachment 命令。
- 一次性统计、筛选、TopN 优先用 `+data-query` 或临时视图;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。
- `formula` 适合常规计算、条件判断、文本/日期处理和长期派生指标;`lookup` 适合明确的跨表查找、筛选后取值或聚合引用。
- 写入、分析、公式、lookup、workflow、dashboard 前,先读取真实结构:表、字段、视图、关联表和 dashboard block 名称都以命令返回为准。
- 跨表场景必须读取目标表结构link 单元格中的关联 `record_id` 只是连接键,最终回答要回查并展示用户可读字段。
### 2.1 模块地图 ## 身份与权限降级
| 大模块 | 处理什么问题 | 包含的小模块 / 能力 | - 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`
|------|-------------|-------------------| - user 身份报 scope/授权不足,或错误中包含 `permission_violations` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
| Base 模块 | 管理 Base 本体,或从链接进入 Base 场景 | `base-create / base-get / base-copy`Base / Wiki 链接解析 | - user 身份报资源级无访问且无授权恢复提示时,才可用 `--as bot` 重试一次bot 仍失败就停止重试并按权限错误处理。
| 表与数据模块 | 管理 Base 内部结构与日常数据操作 | `table / field / record / view` | - `91403` 或明确不可访问错误不要循环换身份重试。
| 公式 / Lookup 模块 | 处理派生字段、条件判断、跨表计算、固定查找引用 | `formula / lookup` 字段创建与更新 | - `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。
| 数据分析模块 | 做一次性筛选、分组、聚合分析 | `data-query` |
| Workflow 模块 | 管理自动化流程 | `workflow-list / get / create / update / enable / disable` |
| Dashboard 模块 | 管理仪表盘和图表组件 | `dashboard-* / dashboard-block-*` |
| 表单模块 | 管理表单和表单题目 | `form-* / form-questions-*` |
| 权限与角色模块 | 管理高级权限和自定义角色 | `advperm-* / role-*` |
### 2.2 Base 模块 ## 查询与统计规则
用于管理 Base 本体,或从用户给出的链接进入后续 Base 操作。 涉及查询、统计或判断结论时,先阅读 `references/lark-base-data-analysis-sop.md`,并遵守:
模块索引:[`references/lark-base-workspace.md`](references/lark-base-workspace.md)
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 | 1. `+record-list` 的默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。
|------|------------------|----------------|----------| 2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉明细到本地上下文再手工筛选排序。
| `lark-cli drive +search --query <keyword> --doc-types bitable` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-drive/references/lark-drive-search.md`](../lark-drive/references/lark-drive-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
| `+base-create` | 创建新的 Base | [`lark-base-base-create.md`](references/lark-base-base-create.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference`--folder-token``--time-zone` 都是可选项 |
| `+base-get` | 获取 Base 信息 | [`lark-base-base-get.md`](references/lark-base-base-get.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 适合确认 Base 本体信息,不替代表/字段结构读取 |
| `+base-copy` | 复制已有 Base | [`lark-base-base-copy.md`](references/lark-base-base-copy.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference复制成功后应主动返回新 Base 标识信息 |
### 2.3 表与数据模块
这是最常用的大模块,包含 `table / field / record / view` 四类子模块。
补充示例:[`references/examples.md`](references/examples.md),适合需要串联 table / record / view 完整操作链路时再读。
#### 2.3.1 Table 子模块
子模块索引:[`references/lark-base-table.md`](references/lark-base-table.md)
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+table-list / +table-get` | 列出数据表,或获取单个表详情 | [`lark-base-table-list.md`](references/lark-base-table-list.md)、[`lark-base-table-get.md`](references/lark-base-table-get.md) | `+table-list` 只能串行执行;`+table-get` 适合删除/修改前确认目标 |
| `+table-create / +table-update / +table-delete` | 创建、更新或删除数据表 | [`lark-base-table-create.md`](references/lark-base-table-create.md)、[`lark-base-table-update.md`](references/lark-base-table-update.md)、[`lark-base-table-delete.md`](references/lark-base-table-delete.md) | 创建适合一次性建表;更新前先确认目标表;删除时用户已明确目标可直接执行并带 `--yes` |
#### 2.3.2 Field 子模块
普通字段管理走这里;如果字段类型是 `formula``lookup`,转到下方“公式 / Lookup 模块”。
子模块索引:[`references/lark-base-field.md`](references/lark-base-field.md)
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+field-list / +field-get` | 列出字段结构,或获取单个字段详情 | [`lark-base-field-list.md`](references/lark-base-field-list.md)、[`lark-base-field-get.md`](references/lark-base-field-get.md) | 写记录、写字段、做分析前常先读 `+field-list``+field-list` 只能串行执行;`+field-get` 适合删除/更新前确认目标 |
| `+field-create / +field-update / +field-delete` | 创建、更新或删除普通字段 | [`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-field-delete.md`](references/lark-base-field-delete.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 写字段前先看字段属性规范;如果涉及类型转换,直接按 `+field-update` 中的字段类型变更规则执行,只在安全白名单内考虑原地转换;如果类型是 `formula / lookup`,先转去读对应 guide更新或删除时用户已明确目标可直接执行并带 `--yes` |
| `+field-search-options` | 查询字段可选项 | [`lark-base-field-search-options.md`](references/lark-base-field-search-options.md) | 适合单选/多选等选项型字段 |
#### 2.3.3 Record 子模块
子模块索引:[`references/lark-base-record.md`](references/lark-base-record.md)、[`references/lark-base-history.md`](references/lark-base-history.md)
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 记录读取统一先读 data analysis SOP已知 `record_id``+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query``+record-get` 支持重复 `--record-id``--json` 读取多条记录 |
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
| `+record-upload-attachment` | 给已有记录上传一个或多个附件 | 看 `lark-cli base +record-upload-attachment --help` | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值;不支持 `--name` |
| `+record-download-attachment` | 下载一个或多个 Base 附件到本地 | 看 `lark-cli base +record-download-attachment --help` | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
| `+record-remove-attachment` | 删除附件字段里的一个或多个附件 | 看 `lark-cli base +record-remove-attachment --help` | 删除操作;确认目标后带 `--yes` |
| `+record-delete` | 删除一条或多条记录 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md) | 删除多条时重复传 `--record-id` 指定多个记录;用户已明确目标可直接执行并带 `--yes` |
| `+record-history-list` | 查询指定记录的变更历史 | [`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 按 `table-id + record-id` 查询,不支持整表扫描;`+record-history-list` 只能串行执行 |
| `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 |
#### 2.3.4 View 子模块
子模块索引:[`references/lark-base-view.md`](references/lark-base-view.md)
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+view-list / +view-get` | 列出视图,或获取视图详情 | [`lark-base-view-list.md`](references/lark-base-view-list.md)、[`lark-base-view-get.md`](references/lark-base-view-get.md) | `+view-list` 只能串行执行;`+view-get` 适合查看已有视图配置 |
| `+view-create / +view-delete / +view-rename` | 创建、删除或重命名视图 | [`lark-base-view-create.md`](references/lark-base-view-create.md)、[`lark-base-view-delete.md`](references/lark-base-view-delete.md)、[`lark-base-view-rename.md`](references/lark-base-view-rename.md) | 创建前先确认表和视图类型;删除前先确认目标;用户已明确新名字时可直接重命名 |
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
| `+view-get-sort / +view-set-sort` | 读取或配置排序 | [`lark-base-view-get-sort.md`](references/lark-base-view-get-sort.md)、[`lark-base-view-set-sort.md`](references/lark-base-view-set-sort.md) | 字段名必须来自真实结构 |
| `+view-get-group / +view-set-group` | 读取或配置分组 | [`lark-base-view-get-group.md`](references/lark-base-view-get-group.md)、[`lark-base-view-set-group.md`](references/lark-base-view-set-group.md) | 字段名必须来自真实结构 |
| `+view-get-visible-fields / +view-set-visible-fields` | 读取或配置视图可见字段 | [`lark-base-view-get-visible-fields.md`](references/lark-base-view-get-visible-fields.md)、[`lark-base-view-set-visible-fields.md`](references/lark-base-view-set-visible-fields.md) | 用于控制视图中的字段顺序与可见性;字段名必须来自真实结构 |
| `+view-get-card / +view-set-card` | 读取或配置卡片视图 | [`lark-base-view-get-card.md`](references/lark-base-view-get-card.md)、[`lark-base-view-set-card.md`](references/lark-base-view-set-card.md) | 适合卡片展示场景 |
| `+view-get-timebar / +view-set-timebar` | 读取或配置时间轴视图 | [`lark-base-view-get-timebar.md`](references/lark-base-view-get-timebar.md)、[`lark-base-view-set-timebar.md`](references/lark-base-view-set-timebar.md) | 适合时间线展示场景 |
### 2.4 公式 / Lookup 模块
只要用户诉求涉及派生指标、条件判断、文本处理、日期差、跨表计算、跨表筛选后取值,都要先判断是否进入本模块。
默认优先考虑 `formula`:适合常规计算、条件判断、文本处理、日期差、跨表聚合,以及需要长期显示在表里的派生结果。
只有当用户明确要求 `lookup`,或场景天然符合 `from / select / where / aggregate` 这种固定查找建模时,再使用 `lookup`
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+field-create``type=formula` | 创建公式字段 | [`formula-field-guide.md`](references/formula-field-guide.md)、[`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 没读 guide 前不要直接创建 |
| `+field-update``type=formula` | 更新公式字段 | [`formula-field-guide.md`](references/formula-field-guide.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 先拿当前表结构 |
| `+field-create``type=lookup` | 创建 lookup 字段 | [`lookup-field-guide.md`](references/lookup-field-guide.md)、[`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 没读 guide 前不要直接创建 |
| `+field-update``type=lookup` | 更新 lookup 字段 | [`lookup-field-guide.md`](references/lookup-field-guide.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 跨表时还要拿目标表结构 |
### 2.5 数据分析模块
用于一次性分析和临时聚合查询。用户要的是“这次算出来的结果”,而不是把结果沉淀成字段时,优先进入本模块。
进入本模块前先确认几件事:
- `+data-query` 只做聚合查询(分组、过滤、排序、聚合计算),不用于列出原始记录或逐条明细。
- 调用者必须是目标多维表格的管理员,拥有目标多维表格的 FAFull Access / 完全访问权限),否则会返回权限错误。
- `+data-query` 只支持白名单字段类型;`formula``lookup`、附件、系统字段、关联等字段不能用于 `dimensions / measures / filters / sort`
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+data-query` | 做分组统计、SUM / AVG / COUNT / MAX / MIN、条件筛选后的聚合分析 | [`lark-base-data-query.md`](references/lark-base-data-query.md) | 字段名必须精确匹配真实字段名;不要用 `+record-list` / `+record-search` 拉全量再手算;`+data-query` 不返回原始记录;使用前先确认权限和字段类型是否受支持 |
### 2.6 Workflow 模块
这是高约束模块。执行任何 workflow 命令前,都必须先读对应命令文档和 schema。
模块索引:[`references/lark-base-workflow.md`](references/lark-base-workflow.md)
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+workflow-list / +workflow-get` | 列出 workflow或获取完整 workflow 结构 | [`lark-base-workflow-list.md`](references/lark-base-workflow-list.md)、[`lark-base-workflow-get.md`](references/lark-base-workflow-get.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | `+workflow-list` 只返回摘要且只能串行执行;需要完整结构时用 `+workflow-get` |
| `+workflow-create / +workflow-update` | 创建或更新 workflow | [`lark-base-workflow-create.md`](references/lark-base-workflow-create.md)、[`lark-base-workflow-update.md`](references/lark-base-workflow-update.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | 先读 schema禁止凭自然语言猜 `type`;先确认真实表名和字段名 |
| `+workflow-enable / +workflow-disable` | 启用或停用 workflow | [`lark-base-workflow-enable.md`](references/lark-base-workflow-enable.md)、[`lark-base-workflow-disable.md`](references/lark-base-workflow-disable.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | 启用或停用前先确认目标 workflow`workflow_id``table_id` 需按前缀区分 |
### 2.7 Dashboard 模块
当用户提到“仪表盘、dashboard、数据看板、图表、可视化、block、组件、添加组件、创建图表”等关键词时进入本模块并先阅读 [`lark-base-dashboard.md`](references/lark-base-dashboard.md)。
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+dashboard-list / +dashboard-get` | 列出仪表盘,或获取仪表盘详情 | [`lark-base-dashboard-list.md`](references/lark-base-dashboard-list.md)、[`lark-base-dashboard-get.md`](references/lark-base-dashboard-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md) | 进入仪表盘语义后先读 guide`+dashboard-list` 只能串行执行 |
| `+dashboard-create / +dashboard-update / +dashboard-delete` | 创建、更新或删除仪表盘 | [`lark-base-dashboard-create.md`](references/lark-base-dashboard-create.md)、[`lark-base-dashboard-update.md`](references/lark-base-dashboard-update.md)、[`lark-base-dashboard-delete.md`](references/lark-base-dashboard-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md) | 创建前先明确看板目标和展示场景;更新前先读取当前配置;删除前先确认目标 |
| `+dashboard-block-list / +dashboard-block-get` | 列出图表组件,或获取单个 block 详情 | [`lark-base-dashboard-block-list.md`](references/lark-base-dashboard-block-list.md)、[`lark-base-dashboard-block-get.md`](references/lark-base-dashboard-block-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | `+dashboard-block-list` 只能串行执行;查看配置细节时读 block config 文档 |
| `+dashboard-block-get-data` | 获取图表组件的计算结果 | [`lark-base-dashboard-block-get-data.md`](references/lark-base-dashboard-block-get-data.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | 适合读取图表组件的最终计算结果;此命令不返回 block 元数据,只返回计算结果 |
| `+dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` | 创建、更新或删除图表组件 | [`lark-base-dashboard-block-create.md`](references/lark-base-dashboard-block-create.md)、[`lark-base-dashboard-block-update.md`](references/lark-base-dashboard-block-update.md)、[`lark-base-dashboard-block-delete.md`](references/lark-base-dashboard-block-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | 涉及 `data_config`、图表类型、filter 时要读 block config 文档;删除前先确认目标 |
### 2.8 表单模块
用于管理表单本体和表单题目。
模块索引:[`references/lark-base-form.md`](references/lark-base-form.md)、[`references/lark-base-form-questions.md`](references/lark-base-form-questions.md)
表单问题相关操作依赖 `form-id`;具体获取方式见 `form-list``form-create` 的 reference。
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+form-list / +form-get` | 列出表单,或获取单个表单 | [`lark-base-form-list.md`](references/lark-base-form-list.md)、[`lark-base-form-get.md`](references/lark-base-form-get.md) | `+form-list` 可用来获取 `form-id``+form-get` 适合查看已有表单配置 |
| `+form-detail` | 通过表单分享链接获取表单详情(含题目列表、字段类型、校验规则) | [`lark-base-form-detail.md`](references/lark-base-form-detail.md) | 只读;仅需 `--share-token`(从分享链接提取),不需要 base-token/table-id/form-id返回的 `questions` 可直接用于 `+form-submit` 构造参数 |
| `+form-submit` | 通过表单分享链接填写并提交表单(支持普通字段 + 附件上传) | [`lark-base-form-submit.md`](references/lark-base-form-submit.md) | 写入操作;仅支持 share_token 模式;**当 `--json` 包含 attachments 时必须额外提供 `--base-token`**(附件上传到 Base Drive Media 需要);附件通过 `--json.attachments` 传入本地路径CLI 自动并行上传 |
| `+form-create / +form-update / +form-delete` | 创建、更新或删除表单 | [`lark-base-form-create.md`](references/lark-base-form-create.md)、[`lark-base-form-update.md`](references/lark-base-form-update.md)、[`lark-base-form-delete.md`](references/lark-base-form-delete.md) | 创建后可继续进入表单问题相关操作;更新或删除前先确认目标表单 |
| `+form-questions-list` | 列出表单题目 | [`lark-base-form-questions-list.md`](references/lark-base-form-questions-list.md) | 适合查看已有题目结构 |
| `+form-questions-create / +form-questions-update / +form-questions-delete` | 创建、更新或删除题目 | [`lark-base-form-questions-create.md`](references/lark-base-form-questions-create.md)、[`lark-base-form-questions-update.md`](references/lark-base-form-questions-update.md)、[`lark-base-form-questions-delete.md`](references/lark-base-form-questions-delete.md) | 先确认 `form-id`;更新或删除前先确认题目目标 |
### 2.9 权限与角色模块
用于启用高级权限,以及管理 Base 自定义角色。
涉及 `+advperm-enable / +advperm-disable / +role-*` 时,操作用户必须为 Base 管理员,否则会返回权限错误。
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+advperm-enable / +advperm-disable` | 启用或停用高级权限 | [`lark-base-advperm-enable.md`](references/lark-base-advperm-enable.md)、[`lark-base-advperm-disable.md`](references/lark-base-advperm-disable.md) | 管理角色前必须先启用;停用是高风险操作,会使已有自定义角色失效 |
| `+role-list / +role-get` | 列出角色,或获取角色详情 | [`lark-base-role-list.md`](references/lark-base-role-list.md)、[`lark-base-role-get.md`](references/lark-base-role-get.md)、[`role-config.md`](references/role-config.md) | `+role-list` 只能串行执行;`+role-get` 适合查看完整权限配置 |
| `+role-create / +role-update / +role-delete` | 创建、更新或删除角色 | [`lark-base-role-create.md`](references/lark-base-role-create.md)、[`lark-base-role-update.md`](references/lark-base-role-update.md)、[`lark-base-role-delete.md`](references/lark-base-role-delete.md)、[`role-config.md`](references/role-config.md) | `+role-create` 仅支持 `custom_role``+role-update` 采用 Delta Merge`role_name``role_type` 即使不改也必须传当前值;`+role-delete` 不可逆 |
## 3. 多维表格通用知识
飞书多维表格英文名是 `Base`,曾用名 `Bitable`;因此旧文档、返回字段、参数名或错误信息里出现 `bitable` 多属历史兼容,不代表应改用另一套命令体系。
### 3.1 字段分类与可写性
| 字段类型 | 含义 | 能否直接作为 `+record-upsert / +record-batch-create / +record-batch-update` 写入目标 | 说明 |
|----------|------|-----------------------------------------------------------|------|
| 存储字段 | 真实存用户输入的数据 | 可以 | 常见如文本、数字、日期、单选、多选、人员、关联 |
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `+record-download-attachment`;删除附件走 `+record-remove-attachment` |
| 地理位置字段 | 存储坐标并由平台解析地址 | 可以 | 写入必须使用 `{lng,lat}`;读取、筛选和转文本等场景使用 `full_address` 字符串;只有公式能访问坐标 |
| 系统字段 | 平台自动维护 | 不可以 | 常见如创建时间、更新时间、创建人、修改人、自动编号 |
| `formula` 字段 | 通过表达式计算 | 不可以 | 只读字段 |
| `lookup` 字段 | 通过跨表规则查找引用 | 不可以 | 只读字段 |
### 3.2 任务选路心智模型
| 用户诉求 | 优先方案 | 不要误走 |
|---------|----------|----------|
| 一次性分析 / 临时统计 | `+data-query` | 不要用 `+record-list` / `+record-search` 拉全量后手算 |
| 要把结果长期显示在表里 | `formula` 字段 | 不要只给一次性手工分析结果 |
| 用户明确要求 lookup或天然是固定查找配置 | `lookup` 字段 | 不要默认先上 lookup先判断 formula 是否更合适 |
| 读取原始记录明细 / 关键词检索 / 导出 | `+record-search / +record-list / +record-get` | 不要拿 `+data-query` 当取数命令 |
| 上传附件到记录 | `+record-upload-attachment` | 不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
| 下载记录里的附件文件 | `+record-download-attachment --record-id <record_id> --output <dir>`,可加 `--file-token <file_token>` 只下指定附件 | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
| 写入地理位置 | `+record-upsert` / `+record-batch-*``{lng,lat}` | 不要把纯地址文本当成 CellValue |
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create``+table-create``+record-upsert` |
### 3.3 查询执行契约
涉及查询、统计或判断结论时,先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),并遵守以下高优先级规则:
1. `+record-list` 默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。
2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询服务中执行;不要先拉明细到本地上下文再手工筛选排序。
3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。 3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。
4. 多表查询必须先确认关系字段和连接键link 单元格里的 `record_id` 是关系键,不是用户可读答案。 4. 多表查询必须先确认关系字段和连接键link 单元格里的 `record_id` 是关系键,不是用户可读答案。
5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。 5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。
6. 一次性分析优先用 `+data-query` 或临时视图;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。
7. `+data-query` 返回聚合结果,不返回原始记录明细;需要输出实体字段时,用聚合结果中的业务 key 或 record_id 再走 record 路径回查。
### 3.4 表名、字段名与表达式引用 ## 写入前置规则
1. 表名、字段名必须精确匹配真实返回,来源应是 `+table-list / +table-get / +field-list` - 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula``lookup` 不作为普通记录写入目标
2. 不要凭自然语言猜名称,不要自行改写用户口述中的表名、字段名 - 附件上传、下载、删除走专用 `+record-*-attachment` 命令
3. `formula / lookup / data-query / workflow` 中出现的名称同样必须精确匹配表达式引用、where 条件、DSL 字段名、workflow 配置都遵守同一规则 - 写字段前先读 `lark-base-field-json.md`;涉及 `formula` / `lookup` 时必须读对应 guide
4. 跨表场景必须额外读取目标表结构,不能只看当前表 - 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确时先用 get/list 消歧。
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
- `+record-batch-update` 是“同值批量更新”:同一份 patch 应用到全部 `record_id_list`,不要拿它做逐行不同值映射。
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list``+field-search-options` 确认可选值。
### 3.5 Token 与链接 ## 表单与视图细节
这是高优先级章节。只要用户输入里出现链接、token或报错涉及 `baseToken` / `wiki_token` / `obj_token`,都应优先回到这里检查 - `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`
- `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
- 临时视图适合一次性筛选/排序后读取;如果筛选结果对用户后续查看有价值,应保留为持久视图并说明名称和用途。
| 输入类型 | 正确处理方式 | 说明 | ## Token 与链接
|---------|--------------|------|
| 直接 Base 链接 `/base/{token}` | 直接提取 token 作为 `--base-token` | 不要把完整 URL 直接作为 `--base-token` |
| Wiki 链接 `/wiki/{token}` | 先用下方 fast path 解析 `data.obj_token` | 不要把 `wiki_token` 直接当 `--base-token`;如果这一步失败,再看 [`lark-wiki-node-get.md`](../lark-wiki/references/lark-wiki-node-get.md) |
| URL 中的 `?table={id}` | 先按前缀判断对象类型 | `tbl` 开头表示数据表 `table-id`,可作为 `--table-id``blk` 开头表示仪表盘 `dashboard-ID``wkf` 开头表示 `workflow-ID``ldx` 开头表示内嵌文档,不要一律当成 `--table-id` |
| URL 中的 `?view={id}` | 提取为 `--view-id` | 适合直接定位视图 |
Wiki Base fast path: | 输入类型 | 含义 / 正确处理方式 |
|---|---|
| `/base/{token}` | 普通 Base 链接;提取 `/base/` 后的 token 作为 `--base-token` |
| `/wiki/{token}` | Wiki 节点链接;先 `wiki +node-get`,当 `data.obj_type=bitable` 时使用 `data.obj_token` 作为 `--base-token` |
| `/base/{token}?table={id}` | `table` 参数用于定位 Base 内对象:`tbl` 开头是数据表 `--table-id``blk` 开头是 dashboard ID`wkf` 开头是 workflow ID |
| `/base/{token}?view={id}` | `view` 参数用于定位表视图,提取为 `--view-id`;通常还需要确认 `table` 参数或先查表结构 |
| `/share/base/form/{shareToken}` | 表单分享链接;这是表单 share token`+form-detail` / `+form-submit --share-token <shareToken>` |
| `/share/base/view/{shareToken}` | 视图分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 |
| `/share/base/dashboard/{shareToken}` | 仪表盘分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 |
| `/record/{shareToken}` | 记录分享链接;暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开。若用户想生成现有记录的分享链接,用 `+record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>` |
| `/base/workspace/{token}` | BaseApp / workspace 链接;暂不支持用 CLI 直接访问 |
```bash `wiki +node-get` 返回非 `bitable` 时,不继续使用 Base 命令:`docx` 转文档,`sheet` 转表格,其他云空间对象转对应 skill 或 drive。
BASE_TOKEN="$(lark-cli wiki +node-get --as user --node-token "<wiki_url_or_token>" --jq '.data | select(.obj_type == "bitable") | .obj_token')"
```
| `lark-cli wiki +node-get` 返回的 `data.obj_type` | 后续路线 | 说明 | ## Dashboard / Workflow / Role
|-----------------------------------------------|----------|------|
| `bitable` | 优先走 `lark-cli base +...` | 如果 shortcut 不覆盖,再用 `lark-cli base <resource> <method>`;不要改走 `lark-cli api /open-apis/bitable/v1/...` |
| `docx` | 转到文档 / Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
| `sheet` | 转到 Sheets 相关 skill | 不继续使用本 skill 的 Base 命令 |
| `slides` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
| `mindnote` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
### 3.6 身份选择与权限降级策略 - Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 `dashboard-block-data-config.md`,组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`
- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 `lark-base-workflow-guide.md` 和 steps JSON SSOT `lark-base-workflow-schema.md`enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。
- Role 的复杂点是权限 JSON。角色操作先读入口 `lark-base-role-guide.md``+role-create` 只支持自定义角色;`+role-update` 是 delta merge角色 create/update 或解读完整配置时读权限 JSON SSOT `role-config.md``+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。
多维表格通常属于用户的个人或团队资源。**默认应优先使用 `--as user`(用户身份)执行所有 Base 操作**,始终显式指定身份。 ## 常见恢复
- **`--as user`(推荐)**:以当前登录用户身份操作其有权访问的 Base。执行前先完成用户授权 | 错误 / 现象 | 恢复动作 |
|---|---|
| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按 `Token 与链接` 重新定位真实 Base token |
| `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token不要立刻改走裸 API |
| `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID注意空格、大小写和跨表字段 |
| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 `lark-base-cell-value.md` 构造 CellValue |
| 日期 / 人员 / 超链接字段报格式错误 | 日期用 `YYYY-MM-DD HH:mm:ss`;人员用 `[{ "id": "ou_xxx" }]`;超链接用 URL 或 markdown link 字符串 |
| formula / lookup 创建失败 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
| `ignored_fields` / `READONLY` | 移除只读字段,只写存储字段 |
| `1254104` | 批量超过 200分批调用 |
| `1254291` | 并发写冲突,串行写入并在批次间短暂等待 |
| `91403` | 无权限访问该 Base`lark-shared` 权限流程处理,不要盲目重试 |
```bash ## 保留 Reference
lark-cli auth login --domain base
```
- **`--as bot`(降级)**:仅当 user 身份权限不足、且 bot 身份确实拥有目标 Base 的访问权限时才降级使用。bot 看不到用户私有资源,行为以应用身份执行。 - `lark-base-data-analysis-sop.md`:查询/统计/全局结论的选路 SOP
- `lark-base-data-query-guide.md` / `lark-base-data-query.md`:聚合查询入口 fewshot 与 DSL SSOT
**执行规则** - `lark-base-cell-value.md`:记录 CellValue 构造
- `lark-base-field-json.md`:字段 JSON 构造
1. 所有操作默认先用 `--as user` - `formula-field-guide.md` / `lookup-field-guide.md`:公式与 lookup 字段
2. 若 user 身份返回权限错误,先判断是否为**不可重试错误码**(如 `91403`)。若是,**立即停止**,不做任何重试或降级,直接按 `lark-shared` 权限不足处理流程引导用户解决。 - `lark-base-field-create.md` / `lark-base-field-update.md`:字段创建/更新命令级补充
3. 非不可重试错误码时,检查错误响应中是否包含 `permission_violations` / `hint` 等提权引导信息: - `lark-base-record-upsert.md` / `lark-base-record-batch-create.md` / `lark-base-record-batch-update.md` / `lark-base-record-history-list.md`:记录写入 JSON 与历史返回解释
- **有提权引导**:按 `lark-shared` 权限不足处理流程,先引导用户完成 user 身份提权(`auth login --scope`);确认提权成功后,以 `--as user` 重试。 - `lark-base-view-set-filter.md`:视图筛选 JSON
- **无提权引导**(如资源级无访问权限、非 scope 不足):切换到 `--as bot` 重试**一次**。 - `lark-base-form-detail.md` / `lark-base-form-submit.md` / `lark-base-form-questions-create.md` / `lark-base-form-questions-update.md`:表单详情、提交和复杂 JSON
4. 若 bot 身份仍然返回权限错误,**立即停止重试**,根据错误响应按 `lark-shared` 流程引导用户解决(引导去开发者后台开通 scope 或确认资源访问权限)。 - `lark-base-dashboard.md` / `dashboard-block-data-config.md` / `lark-base-dashboard-block-get-data.md`:仪表盘、组件配置与图表结果协议
5. 只有在用户明确要求"用应用身份 / bot 身份操作",才跳过 user 直接使用 `--as bot` - `lark-base-workflow-guide.md` / `lark-base-workflow-schema.md`workflow 入口与 steps JSON SSOT
- `lark-base-role-guide.md` / `role-config.md`:角色入口与权限 JSON SSOT
## 4. 执行规则
### 4.1 标准执行顺序
1. 先判断任务属于哪个模块,选对命令族。
2. 如果用户给了链接,先解析 token不要把 wiki token、完整 URL 或其他对象 ID 误当成 `base_token`
3. 如果是查询类任务,先判断问题范围,阅读 data analysis SOP再决定使用 `record / view / data-query`
4. 先拿结构,再写命令,避免猜表名、字段名、表达式引用。
5. 定位到命令后,先读对应 reference再执行命令。
6. 执行命令,并按返回结果判断下一步。
7. 回复时返回关键结果和后续可继续操作的信息,方便 agent 链式执行下一步。
### 4.2 不可违反规则
1. 先拿结构,再写命令;至少先拿当前表结构,跨表时还要拿目标表结构。
2. 不要猜表名、字段名、表达式引用,一律以真实返回为准。
3. 只使用原子命令;不要回退到旧的聚合式 `+table / +field / +record / +view / +history / +workspace`
4. 写记录前先读字段结构;先 `+field-list`,再按 [`lark-base-cell-value.md`](references/lark-base-cell-value.md) 构造 CellValue。
5. 写字段前先看字段属性规范;先读 `lark-base-shortcut-field-properties.md`,再构造 `+field-create / +field-update` 的 JSON。
6. 只写可写字段;系统字段、附件字段、`formula``lookup` 默认不作为普通记录写入目标。
7. 聚合分析与取数分流;统计走 `+data-query`,关键词检索走 `+record-search`,明细走 `+record-list / +record-get`
8. 筛选查询按视图能力执行;先用 `+view-set-filter` 配置筛选,再结合 `+record-list` 读取。
9. 全局查询不得基于默认分页、小 `--limit` 或未证明全量的本地 `jq` 结果下结论。
10. Base 场景不要改走裸 API不要切去 `lark-cli api /open-apis/bitable/v1/...`
11. 统一使用 `--base-token`
12. workflow 场景先读 schema不要凭自然语言猜 `type`
13. dashboard 场景先读 guide提到图表、看板、block 就先进入 dashboard 模块。
14. formula / lookup 场景先读 guide没读 guide 前不要直接创建或更新。
### 4.3 并发、分页与批量限制
- `+table-list / +field-list / +record-list / +view-list / +record-history-list / +role-list / +dashboard-list / +dashboard-block-list / +workflow-list` 禁止并发调用,只能串行执行。
- `+record-list` 分页时,`--limit` 最大 `200`;先拉首批并检查 `has_more`,只有用户明确需要更多数据时再继续翻页。
- 批量写入时,单批不超过 `200` 条。
- 连续写入同一表时,必须串行写入,批次间延迟 `0.51` 秒。
### 4.4 确认与回复规则
- 视图重命名时,用户已明确“把哪个视图改成什么名字”时,`+view-rename` 直接执行即可。
- 更新字段或删除记录 / 字段 / 表时,如果用户已经明确目标,`+field-update / +record-delete / +field-delete / +table-delete` 可直接执行,并带 `--yes`
- 删除目标仍有歧义时,先用 `+record-get / +field-get / +table-get` 或相应 list 命令确认。
- `+base-create / +base-copy` 成功后,回复中必须主动返回新 Base 的标识信息;若结果带可访问链接,也应一并返回。
- 若 Base 由 bot 身份创建或复制shortcut 会自动尝试为当前 CLI 用户补授 `full_access`,并在输出中返回 `permission_grant`agent 不需要再手动编排单独授权。owner 转移必须单独确认,禁止擅自执行。
## 5. 常见错误与恢复
| 错误 / 现象 | 含义 | 恢复动作 |
|-------------|------|----------|
| `1254064` | 日期格式错误 | 传 `YYYY-MM-DD HH:mm:ss` 字符串,不要写相对时间 |
| `1254068` | 超链接格式错误 | `"https://example.com"``"[文本](https://example.com)"` |
| `1254066` | 人员字段错误 | `[{ "id": "ou_xxx" }]` |
| `1254045` | 字段名不存在 | 检查字段名(含空格、大小写) |
| `1254015` | 字段值类型不匹配 | 先 `+field-list`,再按类型构造 |
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --node-token <wiki_url_or_token>` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
| `not found` 且用户给的是 wiki 链接 | 常见于把 wiki token 当成 base token | 优先回退检查 wiki 解析,而不是改走 `bitable/v1` |
| formula / lookup 创建失败 | 指南未读或结构不合法 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
| `ignored_fields` / `READONLY` | 只读字段被当成可写字段常见于系统字段、formula、lookup | 移除只读字段,只写存储字段;计算结果交给 formula / lookup / 系统字段自动产出 |
| `1254104` | 批量超 200 条 | 分批调用 |
| `1254291` | 并发写冲突 | 串行写入 + 批次间延迟 |
| `91403` | 无权限访问该 Base | **不要重试**。按 `lark-shared` 权限不足处理流程引导用户解决权限问题 |

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