Compare commits

..

5 Commits

Author SHA1 Message Date
liangshuo-1
0a0cdc8879 chore(release): v1.0.15 (#575) 2026-04-20 22:03:08 +08:00
zhengquanbin
67e51ec8d7 fix: base role view & record default perm on edit(#530)
fix: address coderabbit review comments on role-config docs

- Update `allow_edit` field description to reflect conditional default:
  `true` when table perm is `edit`, `false` for `read_only` or explicit restriction
- Move `record_operations.delete` out of "默认关闭项" into new "默认开启项(条件性)"
  section to accurately reflect it is default-included when `perm = edit`
- Add `view_rule.allow_edit` to "默认开启项(条件性)" section with same logic

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:08:04 +08:00
sang-neo03
5943a20e2b Feat/auth sidecar proxy (#532)
* feat(sidecar): add sidecar proxy for sandbox credential isolation

Keep real secrets (app_secret, access_token) out of sandbox environments.
CLI instances inside sandboxes connect to a trusted sidecar process via
HTTP; the sidecar verifies HMAC-signed requests and injects real tokens
before forwarding to the Lark API.

Key components:

- `auth proxy` subcommand to start the sidecar server (build tag: authsidecar)
- Noop credential provider returns sentinel tokens in sidecar mode
- Transport interceptor rewrites requests to sidecar with HMAC signature
- Env provider yields to sidecar provider when AUTH_PROXY is set
- Supports both feishu and lark brand endpoints

* feat(sidecar): implement priority ordering for credential providers

* feat(sidecar): strip client-supplied auth headers and improve shutdown logging

* feat(sidecar): buffer request body to prevent HMAC mismatches on read errors

* feat(sidecar): fix CI

* refactor(sidecar): publish protocol package and move server to reference demo

  The sidecar server is no longer shipped as a `lark-cli auth proxy`
  subcommand. Instead, the CLI provides only the standard sidecar *client*
  (via `-tags authsidecar`), while the wire-protocol utilities are exposed
  as a public package for integrators to implement their own server.

  Changes:
  - Move `internal/sidecar/` → `sidecar/` so external integrators can
    import HMAC signing, headers, sentinels and address validators.
  - Remove `cmd/auth/proxy.go`, `proxy_stub.go`, `proxy_test.go` and the
    conditional registration in `cmd/auth/auth.go`.
  - Add `sidecar/server-demo/` — a reference server implementation behind
    the `authsidecar_demo` build tag. It reuses the lark-cli credential
    pipeline for local development; production integrators are expected
    to replace the credential layer with their own secrets source.
  - Update all internal imports from `internal/sidecar` to `sidecar`.

  Rationale:
  - Each integrator has different secrets management / HA / multi-tenant
    requirements, so a one-size-fits-all server doesn't belong in the
    shipped CLI.
  - Keeping the client in-tree guarantees all sandbox-side code stays
    protocol-compatible without a second repo to sync.
  - The public `sidecar/` package pins the wire protocol as a stable
    contract third-party servers must conform to.

  Build matrix after this change:
  - `go build`                         → standard CLI, no sidecar code
  - `go build -tags authsidecar`       → CLI + sidecar client
  - `go build -tags authsidecar_demo \
      ./sidecar/server-demo/`          → reference server binary

  No production users are affected today because the server was not yet
  released; existing sidecar-client users are unchanged.

* feat(sidecar): close 5 pre-release security gaps
  - Server: enforce https-only target (no path/query/userinfo), pin
    forwardURL to https:// — blocks cleartext token leak
  - Protocol v1: canonical now covers version/identity/auth-header,
    blocks identity-flip replay within drift window
  - Client: ValidateProxyAddr requires loopback or same-host alias,
    rejects userinfo and https (interceptor is http-only); cross-machine
    is out of scope
  - Build: non-authsidecar builds exit(2) when AUTH_PROXY is set,
    preventing silent fallback to env credentials
  - Demo: whitelist auth-header to Authorization / X-Lark-MCP-{UAT,TAT},
    blocks token injection into Cookie / UA / X-Forwarded-For exfil paths
2026-04-20 20:24:51 +08:00
kongenpei
cd666422ac fix(base): preserve attachment metadata on base uploads (#563)
* fix: preserve attachment metadata on base uploads

* test: cover attachment mime detection

* fix: address attachment upload review feedback

* fix: preserve source extension for attachment mime detection

* fix: avoid registry test refresh data race

* Revert "fix: avoid registry test refresh data race"

This reverts commit c1d12d0cf1.

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-20 19:14:45 +08:00
mazhe-nerd
9acd121259 fix: update install message (#529) 2026-04-20 12:03:16 +08:00
85 changed files with 4714 additions and 2915 deletions

3
.gitignore vendored
View File

@@ -33,7 +33,8 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
internal/registry/meta_data.json
cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## [v1.0.15] - 2026-04-20
### Features
- **sheets**: Add float image shortcuts (#494)
- **approval**: Document `remind` and `initiated` methods in skill (#554)
### Bug Fixes
- **base**: Preserve attachment metadata on base uploads (#563)
- **base**: Fix role view and record default permission on edit (#530)
- **sheets**: Normalize single-cell range in `+set-style` and `+batch-set-style` (#548)
- **im**: Cap `basic_batch` user_ids at 10 per API limit (#551)
- **install**: Refine install wizard messages (#529)
- **whiteboard**: Deprecate old `lark-whiteboard-cli` skill (#547)
## [v1.0.14] - 2026-04-17
### Features
@@ -404,6 +420,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12

View File

@@ -201,7 +201,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --doc-format markdown --content "<title>Weekly Report</title>\n# Progress\n- Completed feature X"
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -202,7 +202,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --doc-format markdown --content "<title>周报</title>\n# 本周进展\n- 完成了 X 功能"
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

View File

@@ -96,10 +96,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})

View File

@@ -72,7 +72,7 @@ browser. Run it in the background and retrieve the verification URL from its out
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
})

View File

@@ -1,115 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"io"
"os"
"golang.org/x/term"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
// BuildOption configures optional aspects of the command tree construction.
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
}
// WithIO sets the IO streams for the CLI. If not provided, os.Stdin/Stdout/Stderr are used.
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
return func(c *buildConfig) {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
}
c.streams = &cmdutil.IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
}
}
// WithKeychain sets the secret storage backend. If not provided, the platform keychain is used.
func WithKeychain(kc keychain.KeychainAccess) BuildOption {
return func(c *buildConfig) {
c.keychain = kc
}
}
// Build constructs the full command tree without executing.
// Returns only the cobra.Command; Factory is internal.
// Use Execute for the standard production entry point.
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
_, rootCmd := buildInternal(ctx, inv, opts...)
return rootCmd
}
// buildInternal is the internal constructor that also returns Factory for error handling.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
cfg := &buildConfig{
streams: cmdutil.SystemIO(),
}
for _, o := range opts {
o(cfg)
}
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
globals := &GlobalOptions{Profile: inv.Profile}
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
Long: rootLong,
Version: build.Version,
}
rootCmd.SetContext(ctx)
rootCmd.SetIn(cfg.streams.In)
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
return f, rootCmd
}

View File

@@ -1,18 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import "github.com/larksuite/cli/internal/vfs"
// SetDefaultFS replaces the global filesystem implementation used by internal
// packages. The provided fs must implement the vfs.FS interface. If fs is nil,
// the default OS filesystem is restored.
//
// Call this before Build or Execute to take effect.
func SetDefaultFS(fs vfs.FS) {
if fs == nil {
fs = vfs.OsFs{}
}
vfs.DefaultFS = fs
}

View File

@@ -14,6 +14,15 @@ import (
"os"
"strconv"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
@@ -21,6 +30,7 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -85,9 +95,38 @@ func Execute() int {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
configureFlagCompletions(os.Args)
f := cmdutil.NewDefault(inv)
f, rootCmd := buildInternal(context.Background(), inv)
globals := &GlobalOptions{Profile: inv.Profile}
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
Long: rootLong,
Version: build.Version,
}
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
// --- Update check (non-blocking) ---
if !isCompletionCommand(os.Args) {
@@ -151,12 +190,6 @@ func isCompletionCommand(args []string) bool {
return false
}
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
// the invocation will actually serve a __complete request.
func configureFlagCompletions(args []string) {
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
}
// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
func handleRootError(f *cmdutil.Factory, err error) int {

View File

@@ -135,7 +135,7 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := cmdutil.NewDefault(nil, cmdutil.InvocationContext{Profile: profile})
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}

View File

@@ -196,28 +196,3 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}
func TestConfigureFlagCompletions(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
tests := []struct {
name string
args []string
wantDisabled bool
}{
{"plain command", []string{"im", "+send"}, true},
{"help flag", []string{"im", "--help"}, true},
{"no args", []string{}, true},
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
{"completion subcommand", []string{"completion", "bash"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
configureFlagCompletions(tc.args)
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
}
})
}
}

View File

@@ -4,14 +4,12 @@
package schema
import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
@@ -21,7 +19,6 @@ import (
// SchemaOptions holds all inputs for the schema command.
type SchemaOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
// Positional args
Path string
@@ -44,7 +41,7 @@ func printServices(w io.Writer) {
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
}
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
func printResourceList(w io.Writer, spec map[string]interface{}) {
name := registry.GetStrFromMap(spec, "name")
version := registry.GetStrFromMap(spec, "version")
title := registry.GetStrFromMap(spec, "title")
@@ -58,13 +55,9 @@ func printResourceList(w io.Writer, spec map[string]interface{}, mode core.Stric
resources, _ := spec["resources"].(map[string]interface{})
for _, resName := range sortedKeys(resources) {
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
resMap, _ := resources[resName].(map[string]interface{})
methods, _ := resMap["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
if len(methods) == 0 {
continue
}
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
for _, methodName := range sortedKeys(methods) {
m, _ := methods[methodName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
@@ -366,7 +359,6 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
if len(args) > 0 {
opts.Path = args[0]
}
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
@@ -377,7 +369,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
cmd.ValidArgsFunction = completeSchemaPath
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -459,7 +451,6 @@ func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]s
func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
if opts.Path == "" {
printServices(out)
@@ -478,9 +469,9 @@ func schemaRun(opts *SchemaOptions) error {
if len(parts) == 1 {
if opts.Format == "pretty" {
printResourceList(out, spec, mode)
printResourceList(out, spec)
} else {
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
output.PrintJson(out, spec)
}
return nil
}
@@ -501,7 +492,6 @@ func schemaRun(opts *SchemaOptions) error {
if opts.Format == "pretty" {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
@@ -510,26 +500,13 @@ func schemaRun(opts *SchemaOptions) error {
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
} else {
// For JSON output, filter methods in a copy to avoid mutating the registry.
if mode.IsActive() {
filtered := make(map[string]interface{})
for k, v := range resource {
filtered[k] = v
}
if methods, ok := resource["methods"].(map[string]interface{}); ok {
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
}
output.PrintJson(out, filtered)
} else {
output.PrintJson(out, resource)
}
output.PrintJson(out, resource)
}
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var mNames []string
@@ -548,67 +525,3 @@ func schemaRun(opts *SchemaOptions) error {
}
return nil
}
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
// filtered by strict mode. Returns the original spec when strict mode is off.
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() {
return spec
}
result := make(map[string]interface{}, len(spec))
for k, v := range spec {
result[k] = v
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return result
}
filteredRes := make(map[string]interface{}, len(resources))
for resName, resVal := range resources {
resMap, ok := resVal.(map[string]interface{})
if !ok {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
filtered := filterMethodsByStrictMode(methods, mode)
if len(filtered) == 0 {
continue
}
resCopy := make(map[string]interface{}, len(resMap))
for k, v := range resMap {
resCopy[k] = v
}
resCopy["methods"] = filtered
filteredRes[resName] = resCopy
}
result["resources"] = filteredRes
return result
}
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
// Returns the original map unmodified when strict mode is off.
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() || methods == nil {
return methods
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
filtered := make(map[string]interface{}, len(methods))
for name, val := range methods {
m, ok := val.(map[string]interface{})
if !ok {
continue
}
tokens, _ := m["accessTokens"].([]interface{})
if tokens == nil {
filtered[name] = val
continue
}
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == token {
filtered[name] = val
break
}
}
}
return filtered
}

View File

@@ -177,10 +177,11 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
}
}
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})

View File

@@ -3,7 +3,10 @@
package credential
import "sync"
import (
"sort"
"sync"
)
var (
mu sync.Mutex
@@ -11,12 +14,28 @@ var (
)
// Register registers a credential Provider.
// Providers are consulted in registration order.
// Providers are consulted in priority order (lowest value first).
// Providers that implement Priority() int are sorted accordingly;
// those that do not default to priority 10.
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
providers = append(providers, p)
sort.SliceStable(providers, func(i, j int) bool {
return providerPriority(providers[i]) < providerPriority(providers[j])
})
}
// providerPriority returns the priority of a provider.
// If the provider implements interface{ Priority() int }, that value is used;
// otherwise 10 is returned as the default priority.
// Lower values are consulted first.
func providerPriority(p Provider) int {
if pp, ok := p.(interface{ Priority() int }); ok {
return pp.Priority()
}
return 10
}
// Providers returns all registered providers (snapshot).

View File

@@ -37,6 +37,32 @@ func TestRegisterAndProviders(t *testing.T) {
}
}
type priorityProvider struct {
stubProvider
priority int
}
func (p *priorityProvider) Priority() int { return p.priority }
func TestRegister_PriorityOrder(t *testing.T) {
mu.Lock()
old := providers
providers = nil
mu.Unlock()
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
Register(&stubProvider{name: "env"}) // priority 10 (default)
Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first)
got := Providers()
if len(got) != 2 {
t.Fatalf("expected 2, got %d", len(got))
}
if got[0].Name() != "sidecar" || got[1].Name() != "env" {
t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name())
}
}
func TestProviders_ReturnsSnapshot(t *testing.T) {
mu.Lock()
old := providers

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar provides a noop credential provider for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies
// placeholder credentials so the CLI's auth pipeline can proceed normally.
// Real tokens are never present in the sandbox; the sidecar transport
// interceptor routes requests to the trusted sidecar process instead.
package sidecar
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// Provider is the noop credential provider for sidecar mode.
type Provider struct{}
func (p *Provider) Name() string { return "sidecar" }
func (p *Provider) Priority() int { return 0 }
// ResolveAccount returns a minimal Account when sidecar mode is active.
// The account contains AppID and Brand from environment variables, a
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return nil, nil // not in sidecar mode, skip
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
}
}
appID := os.Getenv(envvars.CliAppID)
if appID == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
}
}
if os.Getenv(envvars.CliProxyKey) == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
}
}
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
}
// Parse DefaultAs
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
acct.DefaultAs = id
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
}
}
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
case "bot":
acct.SupportedIdentities = credential.SupportsBot
case "user":
acct.SupportedIdentities = credential.SupportsUser
case "off", "":
acct.SupportedIdentities = credential.SupportsAll
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
}
}
return acct, nil
}
// ResolveToken returns a sentinel token whose value encodes the token type.
// The transport interceptor reads this sentinel to determine the identity
// (user vs bot), strips it, and the sidecar injects the real token.
// Returns nil, nil when sidecar mode is not active.
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
if os.Getenv(envvars.CliAuthProxy) == "" {
return nil, nil
}
var sentinel string
switch req.Type {
case credential.TokenTypeUAT:
sentinel = sidecar.SentinelUAT
case credential.TokenTypeTAT:
sentinel = sidecar.SentinelTAT
default:
return nil, nil
}
return &credential.Token{
Value: sentinel,
Scopes: "", // empty → scope pre-check is skipped
Source: "sidecar",
}, nil
}
func init() {
credential.Register(&Provider{})
}

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package sidecar
import (
"context"
"os"
"testing"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
func setEnv(t *testing.T, key, value string) {
t.Helper()
old, hadOld := os.LookupEnv(key)
os.Setenv(key, value)
t.Cleanup(func() {
if hadOld {
os.Setenv(key, old)
} else {
os.Unsetenv(key)
}
})
}
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, hadOld := os.LookupEnv(key)
os.Unsetenv(key)
t.Cleanup(func() {
if hadOld {
os.Setenv(key, old)
}
})
}
func TestResolveAccount_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct != nil {
t.Fatal("expected nil account when AUTH_PROXY not set")
}
}
func TestResolveAccount_Active(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test123")
setEnv(t, envvars.CliBrand, "lark")
unsetEnv(t, envvars.CliDefaultAs)
unsetEnv(t, envvars.CliStrictMode)
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct == nil {
t.Fatal("expected non-nil account")
}
if acct.AppID != "cli_test123" {
t.Errorf("AppID = %q, want %q", acct.AppID, "cli_test123")
}
if acct.Brand != credential.BrandLark {
t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandLark)
}
if acct.AppSecret != credential.NoAppSecret {
t.Errorf("AppSecret should be NoAppSecret, got %q", acct.AppSecret)
}
if acct.SupportedIdentities != credential.SupportsAll {
t.Errorf("SupportedIdentities = %d, want %d (SupportsAll)", acct.SupportedIdentities, credential.SupportsAll)
}
}
func TestResolveAccount_MissingProxyKey(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
unsetEnv(t, envvars.CliProxyKey)
setEnv(t, envvars.CliAppID, "cli_test")
p := &Provider{}
_, err := p.ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error when PROXY_KEY is missing")
}
if _, ok := err.(*credential.BlockError); !ok {
t.Fatalf("expected BlockError, got %T: %v", err, err)
}
}
func TestResolveAccount_MissingAppID(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
unsetEnv(t, envvars.CliAppID)
p := &Provider{}
_, err := p.ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error when APP_ID is missing")
}
if _, ok := err.(*credential.BlockError); !ok {
t.Fatalf("expected BlockError, got %T: %v", err, err)
}
}
func TestResolveAccount_StrictMode(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test")
tests := []struct {
mode string
want credential.IdentitySupport
}{
{"bot", credential.SupportsBot},
{"user", credential.SupportsUser},
{"off", credential.SupportsAll},
{"", credential.SupportsAll},
}
p := &Provider{}
for _, tt := range tests {
t.Run("strict_"+tt.mode, func(t *testing.T) {
if tt.mode == "" {
unsetEnv(t, envvars.CliStrictMode)
} else {
setEnv(t, envvars.CliStrictMode, tt.mode)
}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct.SupportedIdentities != tt.want {
t.Errorf("SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.want)
}
})
}
}
func TestResolveToken_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)
p := &Provider{}
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tok != nil {
t.Fatal("expected nil token when AUTH_PROXY not set")
}
}
func TestResolveToken_Sentinels(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
p := &Provider{}
// UAT
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("UAT: unexpected error: %v", err)
}
if tok.Value != sidecar.SentinelUAT {
t.Errorf("UAT value = %q, want %q", tok.Value, sidecar.SentinelUAT)
}
if tok.Scopes != "" {
t.Errorf("UAT scopes should be empty, got %q", tok.Scopes)
}
// TAT
tok, err = p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
if err != nil {
t.Fatalf("TAT: unexpected error: %v", err)
}
if tok.Value != sidecar.SentinelTAT {
t.Errorf("TAT value = %q, want %q", tok.Value, sidecar.SentinelTAT)
}
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar provides a transport interceptor for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
// outgoing requests are rewritten to the sidecar address. The interceptor
// strips placeholder credentials, injects proxy headers, and signs each
// request with HMAC-SHA256. No custom DialContext is needed — Go's
// standard http.Transport connects to the sidecar via plain HTTP.
package sidecar
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// Provider implements transport.Provider for the sidecar mode.
type Provider struct{}
func (p *Provider) Name() string { return "sidecar" }
// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active.
// Returns nil when sidecar mode is disabled or the proxy address is invalid;
// in the latter case a warning is emitted to stderr and requests fall back to
// the non-sidecar transport path (where the credential layer will typically
// block them for lack of a valid account).
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return nil
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
return nil
}
key := os.Getenv(envvars.CliProxyKey)
return &Interceptor{
key: []byte(key),
sidecarHost: sidecar.ProxyHost(proxyAddr),
}
}
// Interceptor rewrites requests for the sidecar proxy.
type Interceptor struct {
key []byte // HMAC signing key
sidecarHost string // sidecar host:port for URL rewriting
}
// PreRoundTrip rewrites the request for sidecar routing when it carries a
// sentinel token. Requests without a sentinel token (e.g. pre-signed download
// URLs) are passed through unmodified.
//
// Supports two auth patterns:
// - Standard OpenAPI: Authorization: Bearer <sentinel>
// - MCP protocol: X-Lark-MCP-UAT/TAT: <sentinel>
func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) {
identity, authHeader := detectSentinel(req)
if identity == "" {
return nil // not a sidecar-managed request, pass through
}
// 1. Buffer the body first, before mutating any request state. A partial
// read would sign a truncated body and cause a misleading HMAC mismatch
// on the sidecar side; bail out early and let the request fall through
// unmodified so the credential layer can surface an actionable error.
var bodyBytes []byte
if req.Body != nil {
var err error
bodyBytes, err = io.ReadAll(req.Body)
_ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err)
return nil
}
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if req.GetBody != nil {
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
}
}
}
// 2. Save original target (scheme://host)
originalScheme := "https"
if req.URL.Scheme != "" {
originalScheme = req.URL.Scheme
}
originalHost := req.URL.Host
req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost)
// 3. Set identity and tell sidecar which header to inject real token into
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
// 4. Strip placeholder auth header(s)
req.Header.Del("Authorization")
req.Header.Del(sidecar.HeaderMCPUAT)
req.Header.Del(sidecar.HeaderMCPTAT)
bodySHA := sidecar.BodySHA256(bodyBytes)
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
pathAndQuery := req.URL.RequestURI()
ts := sidecar.Timestamp()
// Cover identity and authHeader in the signature so an on-path attacker
// within the replay window cannot flip the injected token's identity or
// redirect the token into a different header.
sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{
Version: sidecar.ProtocolV1,
Method: req.Method,
Host: originalHost,
PathAndQuery: pathAndQuery,
BodySHA256: bodySHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
})
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderProxySignature, sig)
// 5. Rewrite URL to route through sidecar
req.URL.Scheme = "http"
req.URL.Host = i.sidecarHost
return nil // no post-hook needed
}
// detectSentinel checks both standard Authorization and MCP auth headers for
// sentinel tokens. Returns the identity ("user"/"bot") and the header name
// that carried the sentinel.
//
// Returns ("", "") when the request carries no sentinel token — typically
// requests that require no auth (e.g. pre-signed download URLs where the
// token is embedded in the URL query parameters).
func detectSentinel(req *http.Request) (identity, authHeader string) {
// Check standard Authorization: Bearer <sentinel>
if auth := req.Header.Get("Authorization"); auth != "" {
token := strings.TrimPrefix(auth, "Bearer ")
switch token {
case sidecar.SentinelUAT:
return sidecar.IdentityUser, "Authorization"
case sidecar.SentinelTAT:
return sidecar.IdentityBot, "Authorization"
}
}
// Check MCP headers: X-Lark-MCP-UAT/TAT: <sentinel>
if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT {
return sidecar.IdentityUser, sidecar.HeaderMCPUAT
}
if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT {
return sidecar.IdentityBot, sidecar.HeaderMCPTAT
}
return "", ""
}
func init() {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
return
}
transport.Register(&Provider{})
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package sidecar
import (
"bytes"
"errors"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/sidecar"
)
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
type failingBody struct {
err error
closed bool
readCall bool
}
func (b *failingBody) Read(p []byte) (int, error) {
b.readCall = true
return 0, b.err
}
func (b *failingBody) Close() error {
b.closed = true
return nil
}
func TestInterceptor_PreRoundTrip(t *testing.T) {
key := []byte("test-key-for-hmac-signing-32byte!")
interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"}
body := []byte(`{"msg":"hello"}`)
req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body)))
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
req.Header.Set("X-Cli-Source", "lark-cli")
post := interceptor.PreRoundTrip(req)
if post != nil {
t.Error("expected nil post hook")
}
// URL should be rewritten to sidecar
if req.URL.Scheme != "http" {
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http")
}
if req.URL.Host != "127.0.0.1:16384" {
t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384")
}
// Original target should be preserved
target := req.Header.Get(sidecar.HeaderProxyTarget)
if target != "https://open.feishu.cn" {
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
}
// Identity should be user (from SentinelUAT)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
}
// Authorization should be stripped
if auth := req.Header.Get("Authorization"); auth != "" {
t.Errorf("Authorization header should be stripped, got %q", auth)
}
// HMAC headers should be set
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
t.Error("signature header should be set")
}
if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" {
t.Error("timestamp header should be set")
}
if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" {
t.Error("body SHA256 header should be set")
}
if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 {
t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1)
}
// Non-proxy headers should be preserved
if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" {
t.Errorf("X-Cli-Source should be preserved, got %q", src)
}
// Body should still be readable
readBody, _ := io.ReadAll(req.Body)
if !bytes.Equal(readBody, body) {
t.Errorf("body should be preserved after PreRoundTrip")
}
}
func TestInterceptor_BotIdentity(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
}
}
func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
origURL := "https://some-cdn.example.com/presigned-download?token=abc"
req, _ := http.NewRequest("GET", origURL, nil)
req.Header.Set("Authorization", "Bearer some-real-token")
post := interceptor.PreRoundTrip(req)
// Should NOT be rewritten — no sentinel token
if post != nil {
t.Error("expected nil post hook for pass-through")
}
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged, got %q", req.URL.String())
}
if req.Header.Get(sidecar.HeaderProxyTarget) != "" {
t.Error("proxy target header should not be set for pass-through")
}
if req.Header.Get("Authorization") != "Bearer some-real-token" {
t.Error("Authorization should be preserved for pass-through")
}
}
func TestInterceptor_NoAuth_PassThrough(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
origURL := "https://cdn.feishu.cn/download/file"
req, _ := http.NewRequest("GET", origURL, nil)
interceptor.PreRoundTrip(req)
// No Authorization header at all — should pass through
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String())
}
}
func TestInterceptor_MCP_UAT(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`)))
req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT)
interceptor.PreRoundTrip(req)
// Should be intercepted and rewritten
if req.URL.Host != "127.0.0.1:16384" {
t.Errorf("host = %q, want sidecar host", req.URL.Host)
}
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
}
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT {
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT)
}
// MCP sentinel should be stripped
if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" {
t.Errorf("MCP-UAT should be stripped, got %q", v)
}
}
func TestInterceptor_MCP_TAT(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`)))
req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
}
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT {
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT)
}
}
func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
interceptor.PreRoundTrip(req)
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" {
t.Errorf("auth header = %q, want %q", ah, "Authorization")
}
}
// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request
// body fails partway, PreRoundTrip skips the rewrite entirely rather than
// signing a truncated body (which would produce a misleading HMAC mismatch on
// the sidecar side) and releases the original body.
func TestInterceptor_BodyReadError(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
const origURL = "https://open.feishu.cn/open-apis/im/v1/messages"
body := &failingBody{err: errors.New("disk gremlin")}
req, _ := http.NewRequest("POST", origURL, body)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
post := interceptor.PreRoundTrip(req)
if post != nil {
t.Error("expected nil post hook on body read failure")
}
// Original body must be closed to avoid leaking fd/pipe-like resources.
if !body.readCall {
t.Error("expected ReadAll to have attempted reading from the body")
}
if !body.closed {
t.Error("expected original body to be Close()'d after read failure")
}
// URL must NOT be rewritten — request should fall through to the next
// layer (credential) which can surface a meaningful error.
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String())
}
// No proxy/HMAC headers should leak onto the request.
for _, h := range []string{
sidecar.HeaderProxyVersion,
sidecar.HeaderProxyTarget,
sidecar.HeaderProxySignature,
sidecar.HeaderProxyTimestamp,
sidecar.HeaderBodySHA256,
sidecar.HeaderProxyIdentity,
sidecar.HeaderProxyAuthHeader,
} {
if v := req.Header.Get(h); v != "" {
t.Errorf("%s should not be set on read failure, got %q", h, v)
}
}
}
func TestInterceptor_EmptyBody(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
sha := req.Header.Get(sidecar.HeaderBodySHA256)
expectedEmpty := sidecar.BodySHA256(nil)
if sha != expectedEmpty {
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
}
}

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"sync/atomic"
"github.com/spf13/cobra"
)
// Cobra keeps completion callbacks in a package-global map keyed by
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
// outlive the command itself. Skip registration when the current invocation
// will not serve a completion request.
var flagCompletionsDisabled atomic.Bool
// SetFlagCompletionsDisabled switches RegisterFlagCompletion between
// registering and no-op. Typically set once at process start.
func SetFlagCompletionsDisabled(disabled bool) {
flagCompletionsDisabled.Store(disabled)
}
// FlagCompletionsDisabled reports the current switch state.
func FlagCompletionsDisabled() bool {
return flagCompletionsDisabled.Load()
}
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
// and honors the package switch. The underlying error is swallowed to match
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
if flagCompletionsDisabled.Load() {
return
}
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
}

View File

@@ -1,78 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/spf13/cobra"
)
func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
if FlagCompletionsDisabled() {
t.Fatal("expected default false")
}
SetFlagCompletionsDisabled(true)
if !FlagCompletionsDisabled() {
t.Fatal("expected true after Set(true)")
}
SetFlagCompletionsDisabled(false)
if FlagCompletionsDisabled() {
t.Fatal("expected false after Set(false)")
}
}
// When disabled, a *cobra.Command must be collectable after the caller drops
// its reference — i.e. the wrapper did not touch cobra's global map.
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
SetFlagCompletionsDisabled(true)
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
const N = 5
var collected atomic.Int32
func() {
for range N {
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
})
runtime.SetFinalizer(cmd, func(_ *cobra.Command) { collected.Add(1) })
}
}()
// Finalizers run on a dedicated goroutine after GC; loop to give it time.
for range 30 {
runtime.GC()
time.Sleep(20 * time.Millisecond)
}
if got := collected.Load(); int(got) != N {
t.Fatalf("expected %d *cobra.Command finalizers to fire when completions disabled, got %d", N, got)
}
}
// When enabled, the registered completion must be reachable via cobra.
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
SetFlagCompletionsDisabled(false)
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
want := []cobra.Completion{"a", "b"}
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return want, cobra.ShellCompDirectiveNoFileComp
})
fn, ok := cmd.GetFlagCompletionFunc("foo")
if !ok {
t.Fatal("expected completion func to be registered")
}
got, _ := fn(cmd, nil, "")
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected completion result: %v", got)
}
}

View File

@@ -8,11 +8,13 @@ import (
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"golang.org/x/term"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
@@ -32,26 +34,27 @@ import (
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
if streams == nil {
streams = SystemIO()
}
func NewDefault(inv InvocationContext) *Factory {
f := &Factory{
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
}
f.IOStreams = &IOStreams{
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
}
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc()
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Keychain: f.Keychain,
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
@@ -90,9 +93,9 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
return nil
}
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc() func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
util.WarnIfProxied(f.IOStreams.ErrOut)
util.WarnIfProxied(os.Stderr)
var transport http.RoundTripper = util.NewBaseTransport()
transport = &RetryTransport{Base: transport}
@@ -119,7 +122,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
util.WarnIfProxied(f.IOStreams.ErrOut)
util.WarnIfProxied(os.Stderr)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
@@ -139,7 +142,7 @@ func buildSDKTransport() http.RoundTripper {
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Keychain keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer

View File

@@ -63,7 +63,7 @@ func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(nil, InvocationContext{Profile: "target"})
f := NewDefault(InvocationContext{Profile: "target"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
}
@@ -103,7 +103,7 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(nil, InvocationContext{Profile: "missing"})
f := NewDefault(InvocationContext{Profile: "missing"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
}
@@ -144,7 +144,7 @@ func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(nil, InvocationContext{})
f := NewDefault(InvocationContext{})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
@@ -164,7 +164,7 @@ func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T)
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(nil, InvocationContext{})
f := NewDefault(InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -189,7 +189,7 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(nil, InvocationContext{})
f := NewDefault(InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -217,7 +217,7 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
fileio.Register(provider)
t.Cleanup(func() { fileio.Register(prev) })
f := NewDefault(nil, InvocationContext{})
f := NewDefault(InvocationContext{})
if f.FileIOProvider != provider {
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
}

View File

@@ -4,12 +4,11 @@
package cmdutil
import (
"io"
"testing"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
fn := cachedHttpClientFunc()
c1, err := fn()
if err != nil {
@@ -29,7 +28,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
fn := cachedHttpClientFunc()
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -37,7 +36,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
fn := cachedHttpClientFunc()
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -3,12 +3,7 @@
package cmdutil
import (
"io"
"os"
"golang.org/x/term"
)
import "io"
// IOStreams provides the standard input/output/error streams.
// Commands should use these instead of os.Stdin/Stdout/Stderr
@@ -19,13 +14,3 @@ type IOStreams struct {
ErrOut io.Writer
IsTerminal bool
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
func SystemIO() *IOStreams {
return &IOStreams{
In: os.Stdin, //nolint:forbidigo // entry point for real stdio
Out: os.Stdout, //nolint:forbidigo // entry point for real stdio
ErrOut: os.Stderr, //nolint:forbidigo // entry point for real stdio
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), //nolint:forbidigo // need Fd() for terminal check
}
}

View File

@@ -21,14 +21,11 @@ import (
// DefaultAccountProvider resolves account from config.json via keychain.
type DefaultAccountProvider struct {
keychain func() keychain.KeychainAccess
keychain keychain.KeychainAccess
profile string
}
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
if kc == nil {
kc = keychain.Default
}
func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider {
return &DefaultAccountProvider{keychain: kc, profile: profile}
}
@@ -39,7 +36,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
}
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain, p.profile)
if err != nil {
return nil, err
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/keychain"
)
type noopKC struct{}
@@ -100,7 +99,7 @@ func TestFullChain_ConfigStrictMode(t *testing.T) {
}
ep := &envprovider.Provider{}
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "")
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},

View File

@@ -11,4 +11,8 @@ const (
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
)

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
)
@@ -16,29 +15,6 @@ import (
var knownArrayFields = []string{
"items", "files", "events", "rooms", "records", "nodes",
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
"chats", "messages", "tasks", "created_tasks",
}
// asGenericSlice converts any slice value into []interface{}.
// Returns the slice and true when v is a slice, regardless of element type
// ([]interface{}, []map[string]interface{}, []MyStruct, etc.). This keeps
// formatter logic working when business code uses typed slices.
func asGenericSlice(v interface{}) ([]interface{}, bool) {
if v == nil {
return nil, false
}
if s, ok := v.([]interface{}); ok {
return s, true
}
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice {
return nil, false
}
out := make([]interface{}, rv.Len())
for i := 0; i < rv.Len(); i++ {
out[i] = rv.Index(i).Interface()
}
return out, true
}
// FindArrayField finds the primary array field in a response's data object.
@@ -47,7 +23,7 @@ func asGenericSlice(v interface{}) ([]interface{}, bool) {
func FindArrayField(data map[string]interface{}) string {
for _, name := range knownArrayFields {
if arr, ok := data[name]; ok {
if _, isArr := asGenericSlice(arr); isArr {
if _, isArr := arr.([]interface{}); isArr {
return name
}
}
@@ -55,7 +31,7 @@ func FindArrayField(data map[string]interface{}) string {
// Fallback: lexicographically first array field (deterministic)
var candidates []string
for k, v := range data {
if _, isArr := asGenericSlice(v); isArr {
if _, isArr := v.([]interface{}); isArr {
candidates = append(candidates, k)
}
}
@@ -92,12 +68,11 @@ func toGeneric(v interface{}) interface{} {
// 1. Lark API envelope: result["data"][arrayField] (e.g. {"code":0,"data":{"items":[…]}})
// 2. Direct map: result[arrayField] (e.g. {"members":[…],"total":5})
//
// If data is already a slice, it is returned as a []interface{}. Typed slices
// such as []map[string]interface{} are also accepted via asGenericSlice.
// If data is already a plain []interface{}, it is returned as-is.
func ExtractItems(data interface{}) []interface{} {
resultMap, ok := data.(map[string]interface{})
if !ok {
if arr, ok := asGenericSlice(data); ok {
if arr, ok := data.([]interface{}); ok {
return arr
}
return nil
@@ -106,7 +81,7 @@ func ExtractItems(data interface{}) []interface{} {
// Strategy 1: Lark API envelope — result["data"][arrayField]
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
if field := FindArrayField(dataObj); field != "" {
if items, ok := asGenericSlice(dataObj[field]); ok {
if items, ok := dataObj[field].([]interface{}); ok {
return items
}
}
@@ -115,7 +90,7 @@ func ExtractItems(data interface{}) []interface{} {
// Strategy 2: direct map — result[arrayField]
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
if field := FindArrayField(resultMap); field != "" {
if items, ok := asGenericSlice(resultMap[field]); ok {
if items, ok := resultMap[field].([]interface{}); ok {
return items
}
}

View File

@@ -266,113 +266,6 @@ func TestExtractItems(t *testing.T) {
}
}
// Regression: shortcuts often collect results into typed slices like
// []map[string]interface{} instead of []interface{}. ExtractItems must
// recognise those so --format table/csv/ndjson render the array rather
// than falling back to a key/value view of the envelope.
func TestExtractItems_TypedSlice(t *testing.T) {
cases := []struct {
name string
data interface{}
want int
}{
{
name: "direct map with []map[string]interface{} under known field",
data: map[string]interface{}{
"chats": []map[string]interface{}{
{"chat_id": "oc_a", "name": "Alice"},
{"chat_id": "oc_b", "name": "Bob"},
},
"has_more": true,
"total": float64(2),
},
want: 2,
},
{
name: "envelope with []map[string]interface{} under data.messages",
data: map[string]interface{}{
"data": map[string]interface{}{
"messages": []map[string]interface{}{
{"message_id": "om_1"},
},
},
},
want: 1,
},
{
name: "direct map with []map[string]interface{} under created_tasks",
data: map[string]interface{}{
"created_tasks": []map[string]interface{}{
{"task_id": "t1"},
{"task_id": "t2"},
{"task_id": "t3"},
},
},
want: 3,
},
{
name: "typed slice of structs via fallback",
data: map[string]interface{}{
"widgets": []struct {
Name string `json:"name"`
}{{Name: "x"}, {Name: "y"}},
},
want: 2,
},
{
name: "raw typed slice passed directly",
data: []map[string]interface{}{
{"k": "v"},
},
want: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
items := ExtractItems(tc.data)
if len(items) != tc.want {
t.Fatalf("expected %d items, got %d (%v)", tc.want, len(items), items)
}
})
}
}
// Regression: --format table on the 7 affected shortcuts used to print
// the envelope as a key/value table because the typed slice was ignored.
// After the fix, the array should be expanded into a proper header row.
func TestFormatValue_Table_TypedSlice(t *testing.T) {
data := map[string]interface{}{
"chats": []map[string]interface{}{
{"chat_id": "oc_abc", "name": "Lark test"},
},
"has_more": true,
"total": float64(1),
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatTable)
out := buf.String()
if !strings.Contains(out, "chat_id") {
t.Errorf("table output should expose chat_id column, got:\n%s", out)
}
if !strings.Contains(out, "oc_abc") {
t.Errorf("table output should contain the chat row, got:\n%s", out)
}
// The fallback bug manifested as the envelope being rendered as rows:
// the 'has_more' / 'total' envelope keys would appear as first-column
// labels. A correct render puts the array's element keys in the header
// and keeps envelope metadata out of the table body.
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "has_more") || strings.HasPrefix(trimmed, "total ") {
t.Errorf("envelope field leaked into table body:\n%s", out)
}
}
}
func TestFormatValue_LegacyFormats(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{

11
main_authsidecar.go Normal file
View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package main
import (
_ "github.com/larksuite/cli/extension/credential/sidecar" // activate sidecar credential provider
_ "github.com/larksuite/cli/extension/transport/sidecar" // activate sidecar transport interceptor
)

54
main_noauthsidecar.go Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !authsidecar
// This file is the fail-closed guard for builds that do NOT include the
// `authsidecar` tag. The sidecar credential-isolation feature is only
// compiled in under that tag; deploying the plain build into an environment
// that expects sidecar isolation would silently fall back to direct env
// credential use — exactly the failure mode the feature is meant to prevent.
//
// When LARKSUITE_CLI_AUTH_PROXY is set, we refuse to run rather than ignore
// the variable. The operator either rebuilt without realizing (wrong
// artifact) or the sandbox inherited the var by accident; both cases want
// a loud startup error, not a mysterious token leak on the first API call.
package main
import (
"fmt"
"io"
"os"
"github.com/larksuite/cli/internal/envvars"
)
func init() {
if code := checkNoAuthsidecarBuild(os.Getenv, os.Stderr); code != 0 {
os.Exit(code)
}
}
// checkNoAuthsidecarBuild returns a non-zero exit code (and writes a
// human-readable reason to stderr) when the environment asks for sidecar
// isolation that this binary cannot provide. Factored out from init() so
// tests can exercise the decision without actually calling os.Exit.
func checkNoAuthsidecarBuild(getenv func(string) string, stderr io.Writer) int {
v := getenv(envvars.CliAuthProxy)
if v == "" {
return 0
}
fmt.Fprintf(stderr,
"ERROR: %s is set, but this lark-cli binary was built WITHOUT the "+
"'authsidecar' build tag.\n"+
"The sidecar credential-isolation feature is compiled out — "+
"running would bypass isolation and\n"+
"send any real credentials present in the environment directly "+
"to the Lark API.\n\n"+
"To fix, either:\n"+
" - rebuild the CLI with: go build -tags authsidecar\n"+
" - or unset %s if sidecar isolation is not required\n",
envvars.CliAuthProxy, envvars.CliAuthProxy)
return 2
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !authsidecar
package main
import (
"bytes"
"strings"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
func TestCheckNoAuthsidecarBuild_Unset(t *testing.T) {
var stderr bytes.Buffer
code := checkNoAuthsidecarBuild(func(string) string { return "" }, &stderr)
if code != 0 {
t.Errorf("exit code = %d, want 0 when AUTH_PROXY is unset", code)
}
if stderr.Len() != 0 {
t.Errorf("stderr should be empty, got %q", stderr.String())
}
}
// TestCheckNoAuthsidecarBuild_Set verifies that deploying a plain build into
// a sandbox that expects sidecar isolation fails loudly at startup instead
// of silently leaking credentials through the env provider path.
func TestCheckNoAuthsidecarBuild_Set(t *testing.T) {
var stderr bytes.Buffer
env := func(k string) string {
if k == envvars.CliAuthProxy {
return "http://127.0.0.1:16384"
}
return ""
}
code := checkNoAuthsidecarBuild(env, &stderr)
if code == 0 {
t.Fatal("expected non-zero exit code when AUTH_PROXY is set")
}
msg := stderr.String()
for _, want := range []string{
envvars.CliAuthProxy,
"authsidecar", // build-tag name must appear so operators can act on it
"rebuild",
} {
if !strings.Contains(msg, want) {
t.Errorf("stderr message missing %q; got:\n%s", want, msg)
}
}
}

View File

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

View File

@@ -1,70 +0,0 @@
# Cherry-pick 冲突解决报告: `dd05477`
- **原始 commit**: `dd05477` feat: add SetDefaultFS to allow replacing the global filesystem implementation
- **作者**: tuxedomm, 2026-04-09
- **新 commit**: `4d84994`
- **目标分支**: `feat/main_rebased_420`(基于 `larksuite/cli` 最新 main
## 改动范围
10 个文件, +179 / -70:
- **新增**: `cmd/build.go`, `cmd/init.go`
- **修改**: `cmd/root.go`, `cmd/root_integration_test.go`, `internal/cmdutil/factory_default.go`, `internal/cmdutil/factory_default_test.go`, `internal/cmdutil/factory_http_test.go`, `internal/cmdutil/iostreams.go`, `internal/credential/default_provider.go`, `internal/credential/integration_test.go`
核心意图:
-`cmd.Execute()` 里的 root 命令组装逻辑抽取到新文件 `cmd/build.go``buildInternal()`, 并暴露 `Build()` 作为库入口
- 引入 `cmd/init.go` 里的 `SetDefaultFS(fs vfs.FS)` 允许调用方在 `Build/Execute` 之前替换全局 fs
- `cmdutil.NewDefault(inv)` 签名调整为 `NewDefault(streams *IOStreams, inv InvocationContext)`
- `credentialDeps.Keychain``keychain.KeychainAccess` 改为 `func() keychain.KeychainAccess`(惰性读取, 允许构造后替换)
- `cmdutil.SystemIO()` 新函数封装对真实 stdio 的引用
## 冲突情况
只有一个文件冲突: `cmd/root.go`2 处)
| 位置 | HEAD(main) | fork(dd05477) |
|---|---|---|
| imports 段 | 保留 `cmd/api`, `cmd/auth`, `cmd/completion`, `cmdconfig`, `cmd/doctor`, `cmd/profile`, `cmd/schema`, `cmd/service`, `cmdupdate`, `shortcuts` 等 | 全部删除(这些 import 随 Execute 函数体一起搬去新文件 `cmd/build.go`|
| `Execute()` 函数体 | 完整包含 Factory 构造 + rootCmd 构造 + 子命令注册 + strict-mode 剪枝 | 精简为 `f, rootCmd := buildInternal(context.Background(), inv)` |
### 为什么会冲突
fork 的 dd05477 比 fork 之前落后 main 很多 commit, 而 main 上(比如 PR #391)在 fork 不知道的情况下加了 `rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))` 这一行 —— 它处于 fork 想整体搬走的那段代码里。git 无法自动判断这一行应该保留还是跟着搬, 所以报冲突。
## 解决方案
**两处冲突都采用 fork 的重构结构**(把 imports / 组装逻辑搬去 `cmd/build.go`, 但在 `cmd/build.go``buildInternal()` 里**追加**了 main 新增的 update 命令。
### 具体改动
`cmd/build.go` 里:
```go
// imports 段补上
cmdupdate "github.com/larksuite/cli/cmd/update"
// 在 rootCmd.AddCommand(completion.NewCmdCompletion(f)) 之后追加
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
```
如果不这样做, 就会丢失 main PR #391 引入的 `lark-cli update` 子命令。
## 非冲突文件处理
其余 9 个文件的 patch 全部直接应用, 无语义冲突:
- `cmd/build.go`, `cmd/init.go`: 新增文件
- `cmd/root_integration_test.go`, `internal/cmdutil/factory_default_test.go`, `internal/cmdutil/factory_http_test.go`, `internal/credential/integration_test.go`: 跟随签名变更调整调用方(`NewDefault(nil, ...)``cachedHttpClientFunc(&Factory{...})` 等)
- `internal/cmdutil/factory_default.go`, `internal/cmdutil/iostreams.go`, `internal/credential/default_provider.go`: 签名/结构体字段类型调整
- `cmd/root.go`: 冲突段外其余部分update 检查、错误处理等)保持原样
## 验证
- `go build ./...` 通过
- `go test ./cmd/... ./internal/cmdutil/... ./internal/credential/...` 全部通过
## 依赖
- `internal/vfs` 包(`DefaultFS``OsFs``FS` interface在 main 上已存在, `SetDefaultFS` 要切换的全局状态有完整基础
- `cmdupdate`main PR #391)已存在

View File

@@ -38,11 +38,11 @@ const messages = {
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
step4: "授权",
step4NotFound: "未找到 lark-cli跳过授权",
step4Confirm: "允许 AI 访问你的飞书数据(消息、文档、日历等",
step4Confirm: "是否允许 AI 访问你个人的消息、文档、日历等飞书 / Lark 数据,并以你的名义执行操作",
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
step4Done: "授权完成",
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
done: "安装完成!\n现在可以你的 AI 工具Claude Code、Trae 等)说:\"Feishu/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
done: "安装完成!\n可以你的 AI 工具(Claude Code、Trae等\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
cancelled: "安装已取消",
},
en: {
@@ -66,7 +66,7 @@ const messages = {
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
step4: "Authorization",
step4NotFound: "lark-cli not found. Skipping authorization",
step4Confirm: "Allow AI to access your Feishu/Lark data (messages, docs, calendar, etc.)?",
step4Confirm: "Allow the AI to access your messages, documents, calendar, and more in Feishu/Lark, and perform actions on your behalf?",
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
step4Done: "Authorization complete",
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",

View File

@@ -137,6 +137,8 @@ func TestDryRunRecordOps(t *testing.T) {
"bitable_file",
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
"report-final.pdf",
`"mime_type":"\u003cdetected_mime_type\u003e"`,
`"size":"\u003cfile_size\u003e"`,
"deprecated_set_attachment",
)
}

View File

@@ -1219,7 +1219,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
!strings.Contains(updateBody, `"image_height":480`) ||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
!strings.Contains(updateBody, `"name":"report.txt"`) {
!strings.Contains(updateBody, `"name":"report.txt"`) ||
!strings.Contains(updateBody, `"size":16`) ||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
t.Fatalf("update body=%s", updateBody)
}
})
@@ -1370,6 +1372,8 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if !strings.Contains(updateBody, `"附件"`) ||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
!strings.Contains(updateBody, `"size":20971521`) ||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
t.Fatalf("update body=%s", updateBody)
}

View File

@@ -4,11 +4,15 @@
package base
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"mime"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
@@ -105,6 +109,8 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
map[string]interface{}{
"file_token": "<uploaded_file_token>",
"name": fileName,
"mime_type": "<detected_mime_type>",
"size": "<file_size>",
"deprecated_set_attachment": true,
},
},
@@ -243,10 +249,14 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
}
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
if err != nil {
return nil, err
}
parentNode := baseToken
var (
fileToken string
err error
)
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
@@ -272,7 +282,78 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
attachment := map[string]interface{}{
"file_token": fileToken,
"name": fileName,
"mime_type": mimeType,
"size": fileSize,
"deprecated_set_attachment": true,
}
return attachment, nil
}
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
return stripMIMEParams(byExt), nil
}
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))); byExt != "" {
return stripMIMEParams(byExt), nil
}
f, err := fio.Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer f.Close()
buf := make([]byte, 512)
n, readErr := f.Read(buf)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return "", output.ErrValidation("cannot read file: %s", readErr)
}
return detectAttachmentMIMEFromContent(buf[:n]), nil
}
func stripMIMEParams(value string) string {
if i := strings.IndexByte(value, ';'); i != -1 {
value = value[:i]
}
return strings.TrimSpace(value)
}
func detectAttachmentMIMEFromContent(content []byte) string {
if len(content) == 0 {
return "application/octet-stream"
}
if bytes.HasPrefix(content, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
return "image/png"
}
if bytes.HasPrefix(content, []byte{0xff, 0xd8, 0xff}) {
return "image/jpeg"
}
if bytes.HasPrefix(content, []byte("GIF87a")) || bytes.HasPrefix(content, []byte("GIF89a")) {
return "image/gif"
}
if len(content) >= 12 && bytes.Equal(content[:4], []byte("RIFF")) && bytes.Equal(content[8:12], []byte("WEBP")) {
return "image/webp"
}
if bytes.HasPrefix(content, []byte("%PDF-")) {
return "application/pdf"
}
if looksLikeText(content) {
return "text/plain"
}
return "application/octet-stream"
}
func looksLikeText(content []byte) bool {
if !utf8.Valid(content) {
return false
}
for _, r := range string(content) {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"bytes"
"io"
"io/fs"
"os"
"strings"
"testing"
"github.com/larksuite/cli/extension/fileio"
)
type attachmentTestFileIO struct {
openFile fileio.File
openErr error
}
func (f attachmentTestFileIO) Open(string) (fileio.File, error) { return f.openFile, f.openErr }
func (attachmentTestFileIO) Stat(string) (fileio.FileInfo, error) {
return attachmentTestFileInfo{}, nil
}
func (attachmentTestFileIO) ResolvePath(path string) (string, error) { return path, nil }
func (attachmentTestFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, nil
}
type attachmentTestFileInfo struct{}
func (attachmentTestFileInfo) Size() int64 { return 0 }
func (attachmentTestFileInfo) IsDir() bool { return false }
func (attachmentTestFileInfo) Mode() fs.FileMode { return 0 }
type attachmentTestFile struct {
*bytes.Reader
}
func newAttachmentTestFile(content []byte) attachmentTestFile {
return attachmentTestFile{Reader: bytes.NewReader(content)}
}
func (attachmentTestFile) Close() error { return nil }
type attachmentReadErrorFile struct{}
func (attachmentReadErrorFile) Read([]byte) (int, error) { return 0, os.ErrPermission }
func (attachmentReadErrorFile) ReadAt([]byte, int64) (int, error) { return 0, io.EOF }
func (attachmentReadErrorFile) Close() error { return nil }
func TestDetectAttachmentMIMETypeUsesExtension(t *testing.T) {
got, err := detectAttachmentMIMEType(nil, "ignored", "note.TXT")
if err != nil {
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
}
if got != "text/plain" {
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
}
}
func TestDetectAttachmentMIMETypeFallsBackToSourcePathExtension(t *testing.T) {
got, err := detectAttachmentMIMEType(nil, "report.docx", "report")
if err != nil {
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
}
if got != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" {
t.Fatalf("detectAttachmentMIMEType() = %q, want docx MIME type", got)
}
}
func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
fio := attachmentTestFileIO{openFile: newAttachmentTestFile([]byte("hello from base attachment"))}
got, err := detectAttachmentMIMEType(fio, "note", "note")
if err != nil {
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
}
if got != "text/plain" {
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
}
}
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
fio := attachmentTestFileIO{openErr: os.ErrNotExist}
_, err := detectAttachmentMIMEType(fio, "missing", "missing")
if err == nil {
t.Fatal("expected error for open failure")
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Fatalf("error = %v, want wrapped read failure", err)
}
}
func TestDetectAttachmentMIMETypeReturnsReadError(t *testing.T) {
fio := attachmentTestFileIO{openFile: attachmentReadErrorFile{}}
_, err := detectAttachmentMIMEType(fio, "broken", "broken")
if err == nil {
t.Fatal("expected error for read failure")
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Fatalf("error = %v, want read failure", err)
}
}
func TestDetectAttachmentMIMEFromContent(t *testing.T) {
tests := []struct {
name string
content []byte
want string
}{
{name: "empty", content: nil, want: "application/octet-stream"},
{name: "png", content: []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, want: "image/png"},
{name: "jpeg", content: []byte{0xff, 0xd8, 0xff, 0xe0}, want: "image/jpeg"},
{name: "gif87a", content: []byte("GIF87a"), want: "image/gif"},
{name: "gif89a", content: []byte("GIF89a"), want: "image/gif"},
{name: "webp", content: []byte("RIFF1234WEBP"), want: "image/webp"},
{name: "pdf", content: []byte("%PDF-1.7"), want: "application/pdf"},
{name: "text", content: []byte("hello from base attachment"), want: "text/plain"},
{name: "text with newline", content: []byte("hello\nworld\tok"), want: "text/plain"},
{name: "control bytes", content: []byte{'h', 'i', 0x00}, want: "application/octet-stream"},
{name: "binary fallback", content: []byte{0x00, 0x01, 0x02, 0x03}, want: "application/octet-stream"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectAttachmentMIMEFromContent(tt.content)
if got != tt.want {
t.Fatalf("detectAttachmentMIMEFromContent() = %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -488,46 +488,12 @@ func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
fmt.Fprintln(ctx.IO().Out, string(b))
}
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
// that should be preserved as-is in JSON output.
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
if ctx.JqExpr != "" {
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
if ctx.outputErr == nil {
ctx.outputErr = err
}
}
return
}
enc := json.NewEncoder(ctx.IO().Out)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
_ = enc.Encode(env)
}
// OutFormat prints output based on --format flag.
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
// When JqExpr is set, routes through Out() regardless of format.
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.outFormat(data, meta, prettyFn, false)
}
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
// Use this when the data contains XML/HTML content that should be preserved as-is.
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.outFormat(data, meta, prettyFn, true)
}
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
outFn := ctx.Out
if raw {
outFn = ctx.OutRaw
}
if ctx.JqExpr != "" {
outFn(data, meta)
ctx.Out(data, meta)
return
}
switch ctx.Format {
@@ -535,10 +501,10 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
if prettyFn != nil {
prettyFn(ctx.IO().Out)
} else {
outFn(data, meta)
ctx.Out(data, meta)
}
case "json", "":
outFn(data, meta)
ctx.Out(data, meta)
default:
// table, csv, ndjson — pass data directly; FormatValue handles both
// plain arrays and maps with array fields (e.g. {"members":[…]})
@@ -629,9 +595,6 @@ func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) {
registerShortcutFlags(cmd, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)
parent.AddCommand(cmd)
if shortcut.PostMount != nil {
shortcut.PostMount(cmd)
}
}
// runShortcut is the execution pipeline for a declarative shortcut.
@@ -897,7 +860,7 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
}
if len(fl.Enum) > 0 {
vals := fl.Enum
cmdutil.RegisterFlagCompletion(cmd, fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc(fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return vals, cobra.ShellCompDirectiveNoFileComp
})
}
@@ -913,11 +876,11 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot")
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp
})
if s.HasFormat {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
}

View File

@@ -1,98 +0,0 @@
// 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_FlagCompletionsRegistered exercises the two
// cmdutil.RegisterFlagCompletion call sites in registerShortcutFlagsWithContext:
// the per-flag enum completion (runner.go:879) and the auto-injected --format
// completion (runner.go:895).
func TestShortcutMount_FlagCompletionsRegistered(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
cmdutil.SetFlagCompletionsDisabled(false)
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "docs",
Command: "+fetch",
Description: "fetch doc",
HasFormat: true,
Flags: []Flag{
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+fetch"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// Enum flag completion.
fn, ok := cmd.GetFlagCompletionFunc("sort-by")
if !ok {
t.Fatal("expected completion func for --sort-by")
}
got, _ := fn(cmd, nil, "")
if len(got) != 2 || got[0] != "asc" || got[1] != "desc" {
t.Fatalf("sort-by completion = %v, want [asc desc]", got)
}
// HasFormat-injected --format completion.
fn, ok = cmd.GetFlagCompletionFunc("format")
if !ok {
t.Fatal("expected completion func for --format")
}
got, _ = fn(cmd, nil, "")
want := []string{"json", "pretty", "table", "ndjson", "csv"}
if len(got) != len(want) {
t.Fatalf("format completion = %v, want %v", got, want)
}
for i, v := range want {
if got[i] != v {
t.Fatalf("format completion[%d] = %q, want %q", i, got[i], v)
}
}
}
// TestShortcutMount_FlagCompletionsDisabled verifies the switch actually
// prevents the two registrations from landing in cobra's global map.
func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
cmdutil.SetFlagCompletionsDisabled(true)
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "docs",
Command: "+fetch",
Description: "fetch doc",
HasFormat: true,
Flags: []Flag{
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+fetch"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if _, ok := cmd.GetFlagCompletionFunc("sort-by"); ok {
t.Fatal("did not expect completion func for --sort-by when disabled")
}
if _, ok := cmd.GetFlagCompletionFunc("format"); ok {
t.Fatal("did not expect completion func for --format when disabled")
}
}

View File

@@ -3,11 +3,7 @@
package common
import (
"context"
"github.com/spf13/cobra"
)
import "context"
// Flag.Input source constants.
const (
@@ -47,11 +43,6 @@ type Shortcut struct {
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
// PostMount is an optional hook called after the cobra.Command is fully
// configured (flags registered, tips set) but before it is added to the
// parent. Use it to install custom help functions or tweak the command.
PostMount func(cmd *cobra.Command)
}
// ScopesForIdentity returns the scopes applicable for the given identity.

View File

@@ -7,35 +7,9 @@ import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
func v1CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title", Hidden: true},
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
}
}
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Create(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("content") != "" ||
runtime.Str("parent-token") != "" ||
runtime.Str("parent-position") != ""
}
var DocsCreate = common.Shortcut{
Service: "docs",
Command: "+create",
@@ -43,85 +17,56 @@ var DocsCreate = common.Shortcut{
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
},
v1CreateFlags(),
v2CreateFlags(),
),
Flags: []common.Flag{
{Name: "title", Desc: "document title"},
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "folder-token", Desc: "parent folder token"},
{Name: "wiki-node", Desc: "wiki node token"},
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Create(runtime) {
return validateCreateV2(ctx, runtime)
count := 0
if runtime.Str("folder-token") != "" {
count++
}
return validateCreateV1(ctx, runtime)
if runtime.Str("wiki-node") != "" {
count++
}
if runtime.Str("wiki-space") != "" {
count++
}
if count > 1 {
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if useV2Create(runtime) {
return dryRunCreateV2(ctx, runtime)
args := buildDocsCreateArgs(runtime)
d := common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: create-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
Set("mcp_tool", "create-doc").Set("args", args)
if runtime.IsBot() {
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return dryRunCreateV1(ctx, runtime)
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Create(runtime) {
return executeCreateV2(ctx, runtime)
args := buildDocsCreateArgs(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
return err
}
return executeCreateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
augmentDocsCreateResult(runtime, result)
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
},
}
// ── V1 (MCP) implementation ──
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("markdown") == "" {
return common.FlagErrorf("--markdown is required")
}
count := 0
if runtime.Str("folder-token") != "" {
count++
}
if runtime.Str("wiki-node") != "" {
count++
}
if runtime.Str("wiki-space") != "" {
count++
}
if count > 1 {
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
}
return nil
}
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildCreateArgsV1(runtime)
d := common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: create-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
Set("mcp_tool", "create-doc").Set("args", args)
if runtime.IsBot() {
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return d
}
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+create")
args := buildCreateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
return err
}
augmentCreateResultV1(runtime, result)
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
func buildDocsCreateArgs(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
}
@@ -145,17 +90,18 @@ type docsPermissionTarget struct {
Type string
}
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectPermissionTarget(result)
func augmentDocsCreateResult(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectDocsPermissionTarget(result)
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
result["permission_grant"] = grant
}
}
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parseDocsPermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
return ref
}
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
if docID != "" {
return docsPermissionTarget{Token: docID, Type: "docx"}
@@ -163,14 +109,16 @@ func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget
return docsPermissionTarget{}
}
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
if strings.TrimSpace(docURL) == "" {
return docsPermissionTarget{}, false
}
ref, err := parseDocumentRef(docURL)
if err != nil {
return docsPermissionTarget{}, false
}
switch ref.Kind {
case "wiki":
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
@@ -180,68 +128,3 @@ func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
return docsPermissionTarget{}, false
}
}
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
// whiteboard creation markdown is detected.
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
if !isWhiteboardCreateMarkdown(markdown) {
return
}
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
}
func isWhiteboardCreateMarkdown(markdown string) bool {
lower := strings.ToLower(markdown)
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
return true
}
return strings.Contains(lower, "<whiteboard") &&
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
}
func normalizeBoardTokens(raw interface{}) []string {
switch v := raw.(type) {
case nil:
return []string{}
case []string:
return v
case []interface{}:
tokens := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
tokens = append(tokens, s)
}
}
return tokens
case string:
if v == "" {
return []string{}
}
return []string{v}
default:
return []string{}
}
}
// ── Shared helpers ──
// concatFlags combines multiple flag slices into one.
func concatFlags(slices ...[]common.Flag) []common.Flag {
var out []common.Flag
for _, s := range slices {
out = append(out, s...)
}
return out
}
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
m := make(map[string]string, len(v1)+len(v2))
for _, f := range v1 {
m[f.Name] = "v1"
}
for _, f := range v2 {
m[f.Name] = "v2"
}
return m
}

View File

@@ -9,182 +9,15 @@ import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ── V2 (OpenAPI) tests ──
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_current_user",
"member_type": "openid",
"perm": "full_access",
},
},
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>项目计划</title><h1>目标</h1>",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
}
func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "bot",
})
if err != nil {
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
}
// ── V1 (MCP) tests ──
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
@@ -226,9 +59,77 @@ func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
func TestDocsCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
}
func TestDocsCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
@@ -263,6 +164,12 @@ func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
@@ -273,8 +180,6 @@ func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
}
}
// ── Helpers ──
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
@@ -288,18 +193,6 @@ func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
}
}
func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface{}) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
payload, _ := json.Marshal(result)
reg.Register(&httpmock.Stub{
@@ -321,7 +214,15 @@ func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interfa
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
return mountAndRunDocs(t, DocsCreate, args, f, stdout)
parent := &cobra.Command{Use: "docs"}
DocsCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func decodeDocsCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {

View File

@@ -1,88 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "content", Desc: "document content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder or wiki-node token", Hidden: true},
{Name: "parent-position", Desc: "parent position (e.g. my_library)", Hidden: true},
}
}
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("content") == "" {
return common.FlagErrorf("--content is required")
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive")
}
return nil
}
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCreateBody(runtime)
d := common.NewDryRunAPI().
POST("/open-apis/docs_ai/v1/documents").
Desc("OpenAPI: create document").
Body(body)
if runtime.IsBot() {
d.Desc("After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return d
}
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
body := buildCreateBody(runtime)
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
if err != nil {
return err
}
stripBlockIDs(data)
augmentDocsCreatePermission(runtime, data)
runtime.OutRaw(data, nil)
return nil
}
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"content": runtime.Str("content"),
}
if v := runtime.Str("parent-token"); v != "" {
body["parent_token"] = v
}
if v := runtime.Str("parent-position"); v != "" {
body["parent_position"] = v
}
injectDocsScene(runtime, body)
return body
}
// augmentDocsCreatePermission grants full_access to the current CLI user when
// the document was created with bot identity.
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return
}
docID := strings.TrimSpace(common.GetString(doc, "document_id"))
if docID == "" {
return
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, docID, "docx"); grant != nil {
data["permission_grant"] = grant
}
}

View File

@@ -9,45 +9,9 @@ import (
"io"
"strconv"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path.
func v1FetchFlags() []common.Flag {
return []common.Flag{
{Name: "offset", Desc: "pagination offset", Hidden: true},
{Name: "limit", Desc: "pagination limit", Hidden: true},
}
}
var docsFetchFlagVersions = buildFlagVersionMap(v1FetchFlags(), v2FetchFlags())
// useV2Fetch returns true when the v2 (OpenAPI) fetch path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only
// flags with non-default values (bare "--doc xxx" stays on v1).
func useV2Fetch(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
// --doc-format default is "xml", --detail default is "simple", --revision-id default is -1.
// Only trigger auto-detect when a non-default value is present.
if d := runtime.Str("detail"); d != "" && d != "simple" {
return true
}
if f := runtime.Str("doc-format"); f != "" && f != "xml" {
return true
}
if runtime.Int("revision-id") != -1 {
return true
}
if m := runtime.Str("scope"); m != "" && m != "full" {
return true
}
return false
}
var DocsFetch = common.Shortcut{
Service: "docs",
Command: "+fetch",
@@ -56,81 +20,66 @@ var DocsFetch = common.Shortcut{
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
{Name: "doc", Desc: "document URL or token", Required: true},
},
v1FetchFlags(),
v2FetchFlags(),
),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "offset", Desc: "pagination offset"},
{Name: "limit", Desc: "pagination limit"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if useV2Fetch(runtime) {
return dryRunFetchV2(ctx, runtime)
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
}
return dryRunFetchV1(ctx, runtime)
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
args["offset"] = n
}
if v := runtime.Str("limit"); v != "" {
n, _ := strconv.Atoi(v)
args["limit"] = n
}
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: fetch-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
Set("mcp_tool", "fetch-doc").Set("args", args)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Fetch(runtime) {
return executeFetchV2(ctx, runtime)
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
}
return executeFetchV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
},
}
// ── V1 (MCP) implementation ──
func dryRunFetchV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildFetchArgsV1(runtime)
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: fetch-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
Set("mcp_tool", "fetch-doc").Set("args", args)
}
func executeFetchV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+fetch")
args := buildFetchArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
if err != nil {
return err
}
if md, ok := result["markdown"].(string); ok {
result["markdown"] = fixExportedMarkdown(md)
}
runtime.OutFormat(result, nil, func(w io.Writer) {
if title, ok := result["title"].(string); ok && title != "" {
fmt.Fprintf(w, "# %s\n\n", title)
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
args["offset"] = n
}
if v := runtime.Str("limit"); v != "" {
n, _ := strconv.Atoi(v)
args["limit"] = n
}
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
if err != nil {
return err
}
if md, ok := result["markdown"].(string); ok {
fmt.Fprintln(w, md)
result["markdown"] = fixExportedMarkdown(md)
}
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
}
})
return nil
}
func buildFetchArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"skip_task_detail": true,
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
args["offset"] = n
}
if v := runtime.Str("limit"); v != "" {
n, _ := strconv.Atoi(v)
args["limit"] = n
}
return args
runtime.OutFormat(result, nil, func(w io.Writer) {
if title, ok := result["title"].(string); ok && title != "" {
fmt.Fprintf(w, "# %s\n\n", title)
}
if md, ok := result["markdown"].(string); ok {
fmt.Fprintln(w, md)
}
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
}
})
return nil
},
}

View File

@@ -1,194 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
}
}
func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
}
body := buildFetchBody(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
return common.NewDryRunAPI().
POST(apiPath).
Desc("OpenAPI: fetch document").
Body(body).
Set("document_id", ref.Token)
}
func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
if err := validateFetchDetail(runtime); err != nil {
return err
}
if err := validateReadModeFlags(runtime); err != nil {
return err
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
body := buildFetchBody(runtime)
data, err := doDocAPI(runtime, "POST", apiPath, body)
if err != nil {
return err
}
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
if doc, ok := data["document"].(map[string]interface{}); ok {
if content, ok := doc["content"].(string); ok {
fmt.Fprintln(w, content)
}
}
})
return nil
}
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
}
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v
}
detail := runtime.Str("detail")
switch detail {
case "", "simple":
body["export_option"] = map[string]interface{}{
"export_block_id": false,
"export_style_attrs": false,
"export_cite_extra_data": false,
}
case "with-ids":
body["export_option"] = map[string]interface{}{
"export_block_id": true,
}
case "full":
body["export_option"] = map[string]interface{}{
"export_block_id": true,
"export_style_attrs": true,
"export_cite_extra_data": true,
}
}
if ro := buildReadOption(runtime); ro != nil {
body["read_option"] = ro
}
injectDocsScene(runtime, body)
return body
}
// buildReadOption 拼装 read_option JSONfull/空模式返回 nil让服务端走默认全文路径。
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" || mode == "full" {
return nil
}
ro := map[string]interface{}{"read_mode": mode}
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
ro["start_block_id"] = v
}
if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" {
ro["end_block_id"] = v
}
if v := strings.TrimSpace(runtime.Str("keyword")); v != "" {
ro["keyword"] = v
}
if v := runtime.Int("context-before"); v > 0 {
ro["context_before"] = strconv.Itoa(v)
}
if v := runtime.Int("context-after"); v > 0 {
ro["context_after"] = strconv.Itoa(v)
}
if v := runtime.Int("max-depth"); v >= 0 {
ro["max_depth"] = strconv.Itoa(v)
}
return ro
}
// validateFetchDetail 非 xml 格式markdown/text不承载 block_id 与样式属性,拒绝 with-ids/full。
func validateFetchDetail(runtime *common.RuntimeContext) error {
format := strings.TrimSpace(runtime.Str("doc-format"))
detail := strings.TrimSpace(runtime.Str("detail"))
if format == "" || format == "xml" {
return nil
}
if detail == "with-ids" || detail == "full" {
return fmt.Errorf("--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format)
}
return nil
}
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
func validateReadModeFlags(runtime *common.RuntimeContext) error {
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" || mode == "full" {
return nil
}
if v := runtime.Int("context-before"); v < 0 {
return fmt.Errorf("--context-before must be >= 0, got %d", v)
}
if v := runtime.Int("context-after"); v < 0 {
return fmt.Errorf("--context-after must be >= 0, got %d", v)
}
if v := runtime.Int("max-depth"); v < -1 {
return fmt.Errorf("--max-depth must be >= -1, got %d", v)
}
switch mode {
case "outline":
return nil
case "range":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
return fmt.Errorf("range mode requires --start-block-id or --end-block-id")
}
return nil
case "keyword":
if strings.TrimSpace(runtime.Str("keyword")) == "" {
return fmt.Errorf("keyword mode requires --keyword")
}
return nil
case "section":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" {
return fmt.Errorf("section mode requires --start-block-id")
}
return nil
default:
return fmt.Errorf("invalid --scope %q", mode)
}
}

View File

@@ -1,95 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBuildFetchBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, " DoubaoCLI ")
runtime := newFetchBodyTestRuntime(ctx)
body := buildFetchBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
runtime := newCreateBodyTestRuntime(ctx)
body := buildCreateBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
runtime := newUpdateBodyTestRuntime(ctx)
body := buildUpdateBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
body := buildFetchBody(runtime)
if _, ok := body["scene"]; ok {
t.Fatalf("did not expect empty scene in fetch body: %#v", body)
}
}
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")
cmd.Flags().String("end-block-id", "", "")
cmd.Flags().String("keyword", "", "")
cmd.Flags().Int("context-before", 0, "")
cmd.Flags().Int("context-after", 0, "")
cmd.Flags().Int("max-depth", -1, "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+create"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("content", "<title>hello</title>", "")
cmd.Flags().String("parent-token", "", "")
cmd.Flags().String("parent-position", "", "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+update"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("command", "append", "")
cmd.Flags().Int("revision-id", 0, "")
cmd.Flags().String("content", "<p>hello</p>", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("block-id", "", "")
cmd.Flags().String("src-block-ids", "", "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}

View File

@@ -5,13 +5,12 @@ package doc
import (
"context"
"github.com/spf13/cobra"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var validModesV1 = map[string]bool{
var validModes = map[string]bool{
"append": true,
"overwrite": true,
"replace_range": true,
@@ -21,7 +20,7 @@ var validModesV1 = map[string]bool{
"delete_range": true,
}
var needsSelectionV1 = map[string]bool{
var needsSelection = map[string]bool{
"replace_range": true,
"replace_all": true,
"insert_before": true,
@@ -29,32 +28,6 @@ var needsSelectionV1 = map[string]bool{
"delete_range": true,
}
// v1UpdateFlags returns the flag definitions for the v1 (MCP) update path.
func v1UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Hidden: true},
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')", Hidden: true},
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')", Hidden: true},
{Name: "new-title", Desc: "also update document title", Hidden: true},
}
}
var docsUpdateFlagVersions = buildFlagVersionMap(v1UpdateFlags(), v2UpdateFlags())
// useV2Update returns true when the v2 (OpenAPI) update path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Update(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("command") != "" ||
runtime.Str("content") != "" ||
runtime.Str("pattern") != "" ||
runtime.Str("block-id") != "" ||
runtime.Str("src-block-ids") != ""
}
var DocsUpdate = common.Shortcut{
Service: "docs",
Command: "+update",
@@ -62,104 +35,124 @@ var DocsUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
{Name: "doc", Desc: "document URL or token", Required: true},
},
v1UpdateFlags(),
v2UpdateFlags(),
),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Required: true},
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Input: []string{common.File, common.Stdin}},
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')"},
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')"},
{Name: "new-title", Desc: "also update document title"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Update(runtime) {
return validateUpdateV2(ctx, runtime)
mode := runtime.Str("mode")
if !validModes[mode] {
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
}
return validateUpdateV1(ctx, runtime)
if mode != "delete_range" && runtime.Str("markdown") == "" {
return common.FlagErrorf("--%s mode requires --markdown", mode)
}
selEllipsis := runtime.Str("selection-with-ellipsis")
selTitle := runtime.Str("selection-by-title")
if selEllipsis != "" && selTitle != "" {
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
}
if needsSelection[mode] && selEllipsis == "" && selTitle == "" {
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if useV2Update(runtime) {
return dryRunUpdateV2(ctx, runtime)
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
}
return dryRunUpdateV1(ctx, runtime)
if v := runtime.Str("markdown"); v != "" {
args["markdown"] = v
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
args["selection_with_ellipsis"] = v
}
if v := runtime.Str("selection-by-title"); v != "" {
args["selection_by_title"] = v
}
if v := runtime.Str("new-title"); v != "" {
args["new_title"] = v
}
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: update-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
Set("mcp_tool", "update-doc").Set("args", args)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Update(runtime) {
return executeUpdateV2(ctx, runtime)
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
}
return executeUpdateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
if v := runtime.Str("markdown"); v != "" {
args["markdown"] = v
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
args["selection_with_ellipsis"] = v
}
if v := runtime.Str("selection-by-title"); v != "" {
args["selection_by_title"] = v
}
if v := runtime.Str("new-title"); v != "" {
args["new_title"] = v
}
result, err := common.CallMCPTool(runtime, "update-doc", args)
if err != nil {
return err
}
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
},
}
// ── V1 (MCP) implementation ──
func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
mode := runtime.Str("mode")
if mode == "" {
return common.FlagErrorf("--mode is required")
func normalizeDocsUpdateResult(result map[string]interface{}, markdown string) {
if !isWhiteboardCreateMarkdown(markdown) {
return
}
if !validModesV1[mode] {
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
}
if mode != "delete_range" && runtime.Str("markdown") == "" {
return common.FlagErrorf("--%s mode requires --markdown", mode)
}
selEllipsis := runtime.Str("selection-with-ellipsis")
selTitle := runtime.Str("selection-by-title")
if selEllipsis != "" && selTitle != "" {
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
}
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
}
return nil
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
}
func dryRunUpdateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildUpdateArgsV1(runtime)
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: update-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
Set("mcp_tool", "update-doc").Set("args", args)
func isWhiteboardCreateMarkdown(markdown string) bool {
lower := strings.ToLower(markdown)
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
return true
}
return strings.Contains(lower, "<whiteboard") &&
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
}
func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+update")
args := buildUpdateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "update-doc", args)
if err != nil {
return err
func normalizeBoardTokens(raw interface{}) []string {
switch v := raw.(type) {
case nil:
return []string{}
case []string:
return v
case []interface{}:
tokens := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
tokens = append(tokens, s)
}
}
return tokens
case string:
if v == "" {
return []string{}
}
return []string{v}
default:
return []string{}
}
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
}
if v := runtime.Str("markdown"); v != "" {
args["markdown"] = v
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
args["selection_with_ellipsis"] = v
}
if v := runtime.Str("selection-by-title"); v != "" {
args["selection_by_title"] = v
}
if v := runtime.Str("new-title"); v != "" {
args["new_title"] = v
}
return args
}

View File

@@ -7,32 +7,6 @@ import (
"testing"
)
// ── V2 tests ──
func TestValidCommandsV2(t *testing.T) {
expected := map[string]bool{
"str_replace": true,
"str_delete": true,
"block_delete": true,
"block_insert_after": true,
"block_copy_insert_after": true,
"block_replace": true,
"block_move_after": true,
"overwrite": true,
"append": true,
}
if len(validCommandsV2) != len(expected) {
t.Fatalf("expected %d commands, got %d", len(expected), len(validCommandsV2))
}
for cmd := range validCommandsV2 {
if !expected[cmd] {
t.Fatalf("unexpected command %q in validCommandsV2", cmd)
}
}
}
// ── V1 tests ──
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
t.Run("blank whiteboard tags", func(t *testing.T) {
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
@@ -56,13 +30,13 @@ func TestIsWhiteboardCreateMarkdown(t *testing.T) {
})
}
func TestNormalizeWhiteboardResult(t *testing.T) {
func TestNormalizeDocsUpdateResult(t *testing.T) {
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
result := map[string]interface{}{
"success": true,
}
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
got, ok := result["board_tokens"].([]string)
if !ok {
@@ -78,7 +52,7 @@ func TestNormalizeWhiteboardResult(t *testing.T) {
"board_tokens": []interface{}{"board_1", "board_2"},
}
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
want := []string{"board_1", "board_2"}
got, ok := result["board_tokens"].([]string)
@@ -95,7 +69,7 @@ func TestNormalizeWhiteboardResult(t *testing.T) {
"success": true,
}
normalizeWhiteboardResult(result, "## plain text")
normalizeDocsUpdateResult(result, "## plain text")
if _, ok := result["board_tokens"]; ok {
t.Fatalf("did not expect board_tokens for non-whiteboard markdown")

View File

@@ -1,174 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
var validCommandsV2 = map[string]bool{
"str_replace": true,
"str_delete": true,
"block_delete": true,
"block_insert_after": true,
"block_copy_insert_after": true,
"block_replace": true,
"block_move_after": true,
"overwrite": true,
"append": true,
}
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "command", Desc: "operation: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", Hidden: true, Enum: validCommandsV2Keys()},
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "new content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "regex pattern for str_replace / str_delete", Hidden: true},
{Name: "block-id", Desc: "target block ID for block_* operations", Hidden: true},
{Name: "src-block-ids", Desc: "source block IDs (comma-separated) for block_copy_insert_after / block_move_after", Hidden: true},
{Name: "revision-id", Desc: "base revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
}
}
func validCommandsV2Keys() []string {
return []string{"str_replace", "str_delete", "block_delete", "block_insert_after", "block_copy_insert_after", "block_replace", "block_move_after", "overwrite", "append"}
}
func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
cmd := runtime.Str("command")
if cmd == "" {
return common.FlagErrorf("--command is required")
}
if !validCommandsV2[cmd] {
return common.FlagErrorf("invalid --command %q, valid: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd)
}
content := runtime.Str("content")
pattern := runtime.Str("pattern")
blockID := runtime.Str("block-id")
srcBlockIDs := runtime.Str("src-block-ids")
switch cmd {
case "str_replace":
if pattern == "" {
return common.FlagErrorf("--command str_replace requires --pattern")
}
if content == "" {
return common.FlagErrorf("--command str_replace requires --content")
}
case "str_delete":
if pattern == "" {
return common.FlagErrorf("--command str_delete requires --pattern")
}
case "block_delete":
if blockID == "" {
return common.FlagErrorf("--command block_delete requires --block-id")
}
case "block_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_insert_after requires --block-id")
}
if content == "" {
return common.FlagErrorf("--command block_insert_after requires --content")
}
case "block_copy_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --block-id")
}
if srcBlockIDs == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --src-block-ids")
}
case "block_move_after":
if blockID == "" {
return common.FlagErrorf("--command block_move_after requires --block-id")
}
if content == "" && srcBlockIDs == "" {
return common.FlagErrorf("--command block_move_after requires --content or --src-block-ids")
}
case "block_replace":
if blockID == "" {
return common.FlagErrorf("--command block_replace requires --block-id")
}
if content == "" {
return common.FlagErrorf("--command block_replace requires --content")
}
case "overwrite":
if content == "" {
return common.FlagErrorf("--command overwrite requires --content")
}
case "append":
if content == "" {
return common.FlagErrorf("--command append requires --content")
}
}
return nil
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
}
body := buildUpdateBody(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
Desc("OpenAPI: update document").
Body(body).
Set("document_id", ref.Token)
}
func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
body := buildUpdateBody(runtime)
data, err := doDocAPI(runtime, "PUT", apiPath, body)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
}
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
cmd := runtime.Str("command")
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
blockID := runtime.Str("block-id")
if cmd == "append" {
cmd = "block_insert_after"
blockID = "-1"
}
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"command": cmd,
}
if v := runtime.Int("revision-id"); v != 0 {
body["revision_id"] = v
}
if v := runtime.Str("content"); v != "" {
body["content"] = v
}
if v := runtime.Str("pattern"); v != "" {
body["pattern"] = v
}
if blockID != "" {
body["block_id"] = blockID
}
if v := runtime.Str("src-block-ids"); v != "" {
body["src_block_ids"] = v
}
injectDocsScene(runtime, body)
return body
}

View File

@@ -4,18 +4,12 @@
package doc
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// docsSceneContextKey lets in-process embedders pass a server-owned docs_ai
// scene without exposing it as a user-controlled CLI flag.
const docsSceneContextKey = "lark_cli_docs_scene"
type documentRef struct {
Kind string
Token string
@@ -62,40 +56,6 @@ func extractDocumentToken(raw, marker string) (string, bool) {
return token, true
}
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
return runtime.DoAPIJSON(method, apiPath, nil, body)
}
func docsSceneFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
scene, _ := ctx.Value(docsSceneContextKey).(string)
return strings.TrimSpace(scene)
}
func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}) {
if scene := docsSceneFromContext(runtime.Ctx()); scene != "" {
body["scene"] = scene
}
}
// stripBlockIDs removes "block_id" from each entry in data.document.newblocks.
func stripBlockIDs(data map[string]interface{}) {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return
}
blocks, _ := doc["newblocks"].([]interface{})
for _, b := range blocks {
if m, ok := b.(map[string]interface{}); ok {
delete(m, "block_id")
}
}
}
func buildDriveRouteExtra(docID string) (string, error) {
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
if err != nil {

View File

@@ -1,52 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/shortcuts/common"
)
// installVersionedHelp sets a custom help function on cmd that shows only the
// flags relevant to the selected --api-version. flagVersions maps flag name to
// its version ("v1" or "v2"). Flags not in the map are treated as shared and
// always visible.
func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersions map[string]string) {
origHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
ver, _ := cmd.Flags().GetString("api-version")
if ver == "" {
ver = defaultVersion
}
// Show/hide flags based on the active version.
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if fv, ok := flagVersions[f.Name]; ok {
f.Hidden = fv != ver
}
})
origHelp(cmd, args)
if ver == "v1" {
fmt.Fprintf(cmd.OutOrStdout(),
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
" Use --api-version v2 for the latest API:\n"+
" %s %s --api-version v2 --help\n"+
" Upgrade skill:\n"+
" npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
cmd.Parent().Name(), cmd.Name())
}
})
}
// warnDeprecatedV1 prints a deprecation notice to stderr when the v1 (MCP) code
// path is used, guiding users to upgrade their skill to v2.
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
fmt.Fprintf(runtime.IO().ErrOut,
"[deprecated] docs %s with v1 API is deprecated and will be removed in a future release.\n"+
"Please upgrade your skill: npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
shortcut)
}

View File

@@ -707,29 +707,4 @@ func TestShortcutDryRunShapes(t *testing.T) {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s", formatted)
}
})
t.Run("ImChatMessageList dry run includes root-only query", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"page-size": "20",
"sort": "desc",
}, nil)
formatted := ImChatMessageList.DryRun(context.Background(), runtime).Format()
if !strings.Contains(formatted, "only_thread_root_messages=true") {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
})
}
func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"page-size": "20",
"sort": "desc",
}, nil)
formatted := ImChatMessageList.DryRun(context.Background(), runtime).Format()
if !strings.Contains(formatted, "only_thread_root_messages=true") {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
}

View File

@@ -210,15 +210,14 @@ func TestBuildChatMessageListRequest(t *testing.T) {
}
want := larkcore.QueryParams{
"container_id_type": {"chat"},
"container_id": {"oc_123"},
"sort_type": {"ByCreateTimeAsc"},
"page_size": {"50"},
"only_thread_root_messages": {"true"},
"card_msg_content_type": {"raw_card_content"},
"start_time": {"1772294400"},
"end_time": {"1772467199"},
"page_token": {"next"},
"container_id_type": {"chat"},
"container_id": {"oc_123"},
"sort_type": {"ByCreateTimeAsc"},
"page_size": {"50"},
"card_msg_content_type": {"raw_card_content"},
"start_time": {"1772294400"},
"end_time": {"1772467199"},
"page_token": {"next"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildChatMessageListRequest() = %#v, want %#v", got, want)
@@ -246,13 +245,6 @@ func TestBuildChatMessageListRequest(t *testing.T) {
})
}
func TestChatMessageListOnlyThreadRootMessagesParams(t *testing.T) {
got := buildChatMessageListParams("desc", "20", "oc_123")
if vals := got["only_thread_root_messages"]; !reflect.DeepEqual(vals, []string{"true"}) {
t.Fatalf("only_thread_root_messages = %#v, want true", vals)
}
}
func TestResolveChatIDForMessagesList(t *testing.T) {
t.Run("chat passthrough", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{

View File

@@ -172,12 +172,11 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
pageSize = min(max(n, 1), 50)
}
return larkcore.QueryParams{
"container_id_type": []string{"chat"},
"container_id": []string{chatId},
"sort_type": []string{sortType},
"page_size": []string{strconv.Itoa(pageSize)},
"card_msg_content_type": []string{"raw_card_content"},
"only_thread_root_messages": []string{"true"},
"container_id_type": []string{"chat"},
"container_id": []string{chatId},
"sort_type": []string{sortType},
"page_size": []string{strconv.Itoa(pageSize)},
"card_msg_content_type": []string{"raw_card_content"},
}
}

88
sidecar/hmac.go Normal file
View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sidecar
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// BodySHA256 returns the hex-encoded SHA-256 digest of body.
// An empty or nil body produces the SHA-256 of the empty string.
func BodySHA256(body []byte) string {
h := sha256.Sum256(body)
return hex.EncodeToString(h[:])
}
// CanonicalRequest is the set of fields covered by the HMAC signature.
// Clients and servers must populate every field identically for verification
// to succeed; any field that is forwarded but *not* covered by this struct can
// be tampered with inside the MaxTimestampDrift replay window without
// invalidating the signature.
//
// Version must be set to a known protocol constant (ProtocolV1). It is the
// first field in the canonical string so that a future v2 with different
// structure cannot be confused for v1 output under the same key.
type CanonicalRequest struct {
Version string // e.g. ProtocolV1
Method string // e.g. "GET", "POST"
Host string // e.g. "open.feishu.cn"
PathAndQuery string // e.g. "/open-apis/calendar/v4/events?page_size=50"
BodySHA256 string // hex-encoded SHA-256 of the request body
Timestamp string // Unix epoch seconds string
Identity string // IdentityUser or IdentityBot
AuthHeader string // header the server should inject the real token into
}
// canonicalString joins the fields with newlines. Field order is part of the
// protocol contract — do not reorder without bumping Version.
func (c CanonicalRequest) canonicalString() string {
return strings.Join([]string{
c.Version,
c.Method,
c.Host,
c.PathAndQuery,
c.BodySHA256,
c.Timestamp,
c.Identity,
c.AuthHeader,
}, "\n")
}
// Sign computes the HMAC-SHA256 signature over the canonical request string.
func Sign(key []byte, req CanonicalRequest) string {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(req.canonicalString()))
return hex.EncodeToString(mac.Sum(nil))
}
// Verify checks that signature matches the HMAC-SHA256 of the canonical
// request and that the timestamp is within MaxTimestampDrift seconds of now.
// Returns nil on success.
func Verify(key []byte, req CanonicalRequest, signature string) error {
ts, err := strconv.ParseInt(req.Timestamp, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp %q: %w", req.Timestamp, err)
}
drift := math.Abs(float64(time.Now().Unix() - ts))
if drift > MaxTimestampDrift {
return fmt.Errorf("timestamp drift %.0fs exceeds limit %ds", drift, MaxTimestampDrift)
}
expected := Sign(key, req)
if !hmac.Equal([]byte(expected), []byte(signature)) {
return fmt.Errorf("HMAC signature mismatch")
}
return nil
}
// Timestamp returns the current Unix epoch seconds as a string.
func Timestamp() string {
return strconv.FormatInt(time.Now().Unix(), 10)
}

300
sidecar/hmac_test.go Normal file
View File

@@ -0,0 +1,300 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sidecar
import (
"strconv"
"strings"
"testing"
"time"
)
func TestBodySHA256_Empty(t *testing.T) {
// SHA-256 of empty string is a well-known constant.
want := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
if got := BodySHA256(nil); got != want {
t.Errorf("BodySHA256(nil) = %q, want %q", got, want)
}
if got := BodySHA256([]byte{}); got != want {
t.Errorf("BodySHA256([]byte{}) = %q, want %q", got, want)
}
}
func TestBodySHA256_NonEmpty(t *testing.T) {
got := BodySHA256([]byte(`{"key":"value"}`))
if len(got) != 64 {
t.Errorf("expected 64-char hex string, got %d chars", len(got))
}
}
// canonical is a test helper that builds a fully-populated CanonicalRequest
// with reasonable defaults, so individual tests can override just the field
// they want to tamper with.
func canonical(override func(*CanonicalRequest)) CanonicalRequest {
c := CanonicalRequest{
Version: ProtocolV1,
Method: "POST",
Host: "open.feishu.cn",
PathAndQuery: "/open-apis/im/v1/messages?receive_id_type=chat_id",
BodySHA256: BodySHA256([]byte(`{"content":"hello"}`)),
Timestamp: Timestamp(),
Identity: IdentityUser,
AuthHeader: "Authorization",
}
if override != nil {
override(&c)
}
return c
}
func TestSignAndVerify(t *testing.T) {
key := []byte("test-secret-key-32bytes-long!!!!!")
req := canonical(nil)
sig := Sign(key, req)
if len(sig) != 64 {
t.Fatalf("signature should be 64-char hex, got %d chars", len(sig))
}
// Valid verification
if err := Verify(key, req, sig); err != nil {
t.Fatalf("Verify failed for valid signature: %v", err)
}
// Wrong key
if err := Verify([]byte("wrong-key"), req, sig); err == nil {
t.Error("Verify should fail with wrong key")
}
// Each field must be covered by the signature — tampering with any one
// invalidates it.
fields := map[string]func(*CanonicalRequest){
"version": func(c *CanonicalRequest) { c.Version = "v2" },
"method": func(c *CanonicalRequest) { c.Method = "GET" },
"host": func(c *CanonicalRequest) { c.Host = "evil.com" },
"pathAndQuery": func(c *CanonicalRequest) { c.PathAndQuery = "/steal" },
"bodySHA256": func(c *CanonicalRequest) { c.BodySHA256 = BodySHA256([]byte("tampered")) },
"identity": func(c *CanonicalRequest) { c.Identity = IdentityBot },
"authHeader": func(c *CanonicalRequest) { c.AuthHeader = "Cookie" },
}
for name, mutate := range fields {
t.Run("tamper_"+name, func(t *testing.T) {
tampered := canonical(mutate)
if err := Verify(key, tampered, sig); err == nil {
t.Errorf("Verify should fail when %s is tampered", name)
}
})
}
}
// TestVerify_PrivilegeConfusion proves C1: without identity and authHeader in
// the canonical string, an attacker holding a captured user-signed request
// could replay it as bot (or vice versa) by flipping the header. With both
// fields now covered, such a flip must invalidate the signature.
func TestVerify_PrivilegeConfusion(t *testing.T) {
key := []byte("test-key")
signed := canonical(func(c *CanonicalRequest) { c.Identity = IdentityUser })
sig := Sign(key, signed)
replayed := signed
replayed.Identity = IdentityBot // attacker flips identity
if err := Verify(key, replayed, sig); err == nil {
t.Error("identity flip must invalidate signature")
}
replayed = signed
replayed.AuthHeader = "Cookie" // attacker redirects injection target
if err := Verify(key, replayed, sig); err == nil {
t.Error("auth-header flip must invalidate signature")
}
}
func TestVerify_TimestampDrift(t *testing.T) {
key := []byte("test-key")
// Timestamp too old
oldTs := strconv.FormatInt(time.Now().Unix()-MaxTimestampDrift-10, 10)
oldReq := canonical(func(c *CanonicalRequest) { c.Timestamp = oldTs })
sig := Sign(key, oldReq)
if err := Verify(key, oldReq, sig); err == nil {
t.Error("Verify should reject expired timestamp")
}
// Timestamp too far in future
futureTs := strconv.FormatInt(time.Now().Unix()+MaxTimestampDrift+10, 10)
futureReq := canonical(func(c *CanonicalRequest) { c.Timestamp = futureTs })
sig = Sign(key, futureReq)
if err := Verify(key, futureReq, sig); err == nil {
t.Error("Verify should reject future timestamp")
}
// Invalid timestamp
badTs := canonical(func(c *CanonicalRequest) { c.Timestamp = "not-a-number" })
if err := Verify(key, badTs, "sig"); err == nil {
t.Error("Verify should reject invalid timestamp")
}
}
func TestSignDeterministic(t *testing.T) {
key := []byte("key")
req := canonical(func(c *CanonicalRequest) { c.Timestamp = "12345" })
a, b := Sign(key, req), Sign(key, req)
if a != b {
t.Errorf("Sign should be deterministic: %q vs %q", a, b)
}
}
func TestValidateProxyAddr(t *testing.T) {
valid := []string{
// loopback IPs
"http://127.0.0.1:16384",
"127.0.0.1:16384",
"[::1]:16384",
"http://[::1]:16384",
// recognized same-host aliases
"http://localhost:8080",
"localhost:8080",
"http://host.docker.internal:16384",
"http://host.containers.internal:16384",
"http://host.lima.internal:16384",
"http://gateway.docker.internal:16384",
// trailing slash is tolerated
"http://127.0.0.1:8080/",
}
for _, addr := range valid {
if err := ValidateProxyAddr(addr); err != nil {
t.Errorf("ValidateProxyAddr(%q) unexpected error: %v", addr, err)
}
}
invalid := []string{
"",
"foobar",
"ftp://127.0.0.1:16384",
"http://",
"http://127.0.0.1:16384/some/path",
":16384",
}
for _, addr := range invalid {
if err := ValidateProxyAddr(addr); err == nil {
t.Errorf("ValidateProxyAddr(%q) expected error, got nil", addr)
}
}
}
// TestValidateProxyAddr_HostConstraint pins C2: the sidecar pattern is
// same-machine by definition, so the validator rejects any host that isn't
// loopback or a recognized same-host alias. Tampered /etc/hosts is out of
// scope (attacker already has ambient host access).
func TestValidateProxyAddr_HostConstraint(t *testing.T) {
sameHost := []string{
"http://127.0.0.1:16384",
"http://localhost:8080",
"http://host.docker.internal:16384",
"http://host.containers.internal:16384",
"http://host.lima.internal:16384",
"http://gateway.docker.internal:16384",
"http://[::1]:16384",
// bare form
"127.0.0.1:16384",
"localhost:8080",
"host.docker.internal:16384",
}
for _, addr := range sameHost {
if err := ValidateProxyAddr(addr); err != nil {
t.Errorf("expected %q to pass as same-host, got: %v", addr, err)
}
}
notSameHost := map[string]string{
// The interesting ones — plausible misconfigurations / attacks
"public DNS name": "http://attacker.com:8080",
"cloud metadata IMDS": "http://169.254.169.254",
"private RFC1918": "http://10.0.0.1:16384",
"other RFC1918": "http://192.168.1.2:16384",
"link-local IPv4": "http://169.254.1.1:16384",
"unspecified IPv4 (0.0.0.0)": "http://0.0.0.0:16384",
"bare public IP": "http://8.8.8.8:16384",
"bare RFC1918": "10.0.0.1:16384",
}
for name, addr := range notSameHost {
t.Run(name, func(t *testing.T) {
err := ValidateProxyAddr(addr)
if err == nil {
t.Fatalf("expected rejection for %q", addr)
}
// Error must name the constraint so users know why.
msg := err.Error()
if !strings.Contains(msg, "loopback") && !strings.Contains(msg, "same-host") {
t.Errorf("error should explain same-host requirement, got: %v", err)
}
})
}
}
// TestValidateProxyAddr_RejectsUserinfo closes the URL-phishing vector
// http://127.0.0.1@attacker.com (where "127.0.0.1" is actually basic-auth
// userinfo and the real host is attacker.com). userinfo has no legitimate
// use in the sidecar protocol.
func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
for _, addr := range []string{
"http://user@127.0.0.1:16384",
"http://user:pass@127.0.0.1:16384",
"http://127.0.0.1@attacker.com:16384",
} {
err := ValidateProxyAddr(addr)
if err == nil {
t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr)
continue
}
// Either "userinfo" (for addresses parsed with user) or the same-host
// message (for e.g. http://127.0.0.1@attacker.com where the REAL
// host parses as attacker.com) is acceptable — both reject the
// phishing attempt.
msg := err.Error()
if !strings.Contains(msg, "userinfo") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") {
t.Errorf("error should reject userinfo or flag wrong host, got: %v", err)
}
}
}
// TestValidateProxyAddr_HTTPSRejected pins the current contract: https is
// rejected explicitly (not lumped into a generic "bad scheme" error) because
// the interceptor hardcodes http and would silently downgrade an https URL
// otherwise. The message must mention https so users understand why their
// perfectly-looking config is refused.
func TestValidateProxyAddr_HTTPSRejected(t *testing.T) {
for _, addr := range []string{
"https://127.0.0.1:16384",
"https://sidecar.corp.internal:443",
} {
err := ValidateProxyAddr(addr)
if err == nil {
t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr)
continue
}
if !strings.Contains(err.Error(), "https") {
t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err)
}
}
}
func TestProxyHost(t *testing.T) {
tests := []struct {
input string
want string
}{
{"http://127.0.0.1:16384", "127.0.0.1:16384"},
{"http://0.0.0.0:8080", "0.0.0.0:8080"},
{"http://host.docker.internal:16384/", "host.docker.internal:16384"},
{"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := ProxyHost(tt.input); got != tt.want {
t.Errorf("ProxyHost(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

198
sidecar/protocol.go Normal file
View File

@@ -0,0 +1,198 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package sidecar defines the wire protocol shared between the CLI client
// (running inside a sandbox) and the auth sidecar proxy (running in a
// trusted environment). Communication uses plain HTTP.
package sidecar
import (
"fmt"
"net"
"net/url"
"strings"
)
// ProtocolV1 is the wire-protocol version string embedded in every signed
// request. Servers must reject requests whose HeaderProxyVersion is not a
// version they understand. Bump this constant (and update the canonical
// string) for any breaking change to signing inputs.
const ProtocolV1 = "v1"
// Proxy request headers set by the CLI transport interceptor.
const (
// HeaderProxyVersion carries the wire-protocol version (e.g. ProtocolV1).
// Servers must reject requests whose version they do not understand. The
// value is also included in the canonical signing string so that a request
// signed for one version cannot be replayed as another.
HeaderProxyVersion = "X-Lark-Proxy-Version"
// HeaderProxyTarget carries the original request host (e.g. "open.feishu.cn").
HeaderProxyTarget = "X-Lark-Proxy-Target"
// HeaderProxyIdentity carries the resolved identity type ("user" or "bot").
HeaderProxyIdentity = "X-Lark-Proxy-Identity"
// HeaderProxySignature carries the HMAC-SHA256 hex signature.
HeaderProxySignature = "X-Lark-Proxy-Signature"
// HeaderProxyTimestamp carries the Unix epoch seconds string used in signing.
HeaderProxyTimestamp = "X-Lark-Proxy-Timestamp"
// HeaderBodySHA256 carries the hex-encoded SHA-256 digest of the request body.
HeaderBodySHA256 = "X-Lark-Body-SHA256"
// HeaderProxyAuthHeader tells the sidecar which header to inject the real
// token into. Defaults to "Authorization" for standard OpenAPI requests.
// MCP requests use "X-Lark-MCP-UAT" or "X-Lark-MCP-TAT".
HeaderProxyAuthHeader = "X-Lark-Proxy-Auth-Header"
)
// MCP auth headers used by the Lark MCP protocol.
const (
HeaderMCPUAT = "X-Lark-MCP-UAT"
HeaderMCPTAT = "X-Lark-MCP-TAT"
)
// Sentinel token values returned by the noop credential provider.
// These are placeholder strings that flow through the SDK auth pipeline
// but are stripped by the transport interceptor before reaching the sidecar.
const (
SentinelUAT = "sidecar-managed-uat" // User Access Token placeholder
SentinelTAT = "sidecar-managed-tat" // Tenant Access Token placeholder
)
// IdentityUser and IdentityBot are the wire values for HeaderProxyIdentity.
const (
IdentityUser = "user"
IdentityBot = "bot"
)
// MaxTimestampDrift is the maximum allowed difference (in seconds) between
// the request timestamp and the server's current time.
const MaxTimestampDrift = 60
// DefaultListenAddr is the default sidecar listen address (localhost only).
const DefaultListenAddr = "127.0.0.1:16384"
// sameHostAliases names DNS aliases commonly used to reach the host running
// the sandbox across a container / VM boundary. Traffic to these names stays
// on the physical machine (via a virtual bridge), so a plaintext sidecar
// channel still satisfies the sidecar pattern's same-host confidentiality
// requirement. Adding to this list has real security implications — only add
// names that are universally same-host by the runtime's design.
var sameHostAliases = map[string]bool{
"localhost": true, // universal
"host.docker.internal": true, // Docker Desktop (macOS / Windows)
"host.containers.internal": true, // Podman Desktop
"host.lima.internal": true, // Lima / colima / rancher-desktop
"gateway.docker.internal": true, // Docker Desktop alt name
}
// isSameHost returns true when host is either a loopback IP or a recognized
// same-host DNS alias. Does not perform DNS resolution — a tampered /etc/hosts
// that points an alias elsewhere is out of scope (attacker with that access
// already has ambient control of the machine).
func isSameHost(host string) bool {
if sameHostAliases[host] {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}
// errNotSameHost is the shared error returned when the sidecar address does
// not resolve to the same physical host as the sandbox. Kept in one place so
// tests can look for a stable marker.
func errNotSameHost(addr string) error {
return fmt.Errorf("invalid proxy address %q: host must be loopback "+
"(127.0.0.1 / ::1) or a recognized same-host alias "+
"(localhost, host.docker.internal, host.containers.internal, "+
"host.lima.internal, gateway.docker.internal). "+
"The sidecar must run on the same physical machine as the sandbox — "+
"cross-machine deployment is not a sidecar and is not supported", addr)
}
// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value.
// Accepted formats:
// - http://host:port
// - host:port (bare address, treated as http)
//
// Host must be loopback or in sameHostAliases. The sidecar pattern is
// inherently same-machine; cross-machine deployment is a different product
// and is not supported by this feature.
//
// https:// is rejected because sidecar is a same-host pattern: loopback
// and virtual same-host bridges don't traverse any untrusted medium, so
// TLS adds no security. Cross-machine deployment is out of scope (see the
// host constraint above), so there is no scenario today where https
// provides a real benefit over http on loopback.
//
// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol
// does not use basic auth, and the syntactic slot exists only as a phishing
// vector (e.g. http://127.0.0.1@attacker.com).
//
// Returns an error if the value is not a valid proxy address.
func ValidateProxyAddr(addr string) error {
if addr == "" {
return fmt.Errorf("proxy address is empty")
}
// Bare host:port (no scheme) — validate as a net address.
if !strings.Contains(addr, "://") {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid proxy address %q: expected host:port or http://host:port", addr)
}
if host == "" || port == "" {
return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr)
}
if !isSameHost(host) {
return errNotSameHost(addr)
}
return nil
}
u, err := url.Parse(addr)
if err != nil {
return fmt.Errorf("invalid proxy address %q: %w", addr, err)
}
if u.User != nil {
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 == "" {
return fmt.Errorf("invalid proxy address %q: missing host", addr)
}
if u.Path != "" && u.Path != "/" {
return fmt.Errorf("invalid proxy address %q: path is not allowed", addr)
}
// u.Hostname() strips the port and unwraps IPv6 brackets.
if !isSameHost(u.Hostname()) {
return errNotSameHost(addr)
}
return nil
}
// 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".
// Returns the host:port portion for URL rewriting.
func ProxyHost(authProxy string) string {
// Strip scheme
host := authProxy
if i := strings.Index(host, "://"); i >= 0 {
host = host[i+3:]
}
// Strip trailing slash
host = strings.TrimRight(host, "/")
return host
}

View File

@@ -0,0 +1,197 @@
# Sidecar Server Reference Implementation
> ⚠️ **This is a demo.** For production deployment, implement your own sidecar
> server conforming to the wire protocol in `github.com/larksuite/cli/sidecar`.
This example shows how to implement a sidecar auth proxy server that receives
HMAC-signed requests from lark-cli sandbox clients and forwards them to the
Lark/Feishu API with real credentials injected.
## What this demo shows
- HMAC-SHA256 request verification (timestamp drift, body digest, signature)
- Target host allowlist + https-only target validation (anti-SSRF / anti-downgrade)
- Identity-based token resolution (UAT for user, TAT for bot)
- Auth-header allowlist: real token may only be injected into `Authorization`
/ `X-Lark-MCP-UAT` / `X-Lark-MCP-TAT`, rejecting attempts to smuggle it into
`Cookie`, `User-Agent`, or other intermediate-logged headers
- Audit logging with path ID-segment sanitization and upstream error truncation
- Safe request forwarding (strips client-supplied auth headers)
## What this demo does NOT handle
- **TAT refresh** — the shared `DefaultTokenProvider` caches the TAT via
`sync.Once`, which never refreshes. A long-running server will return an
expired TAT after 2 hours. Production implementations should maintain a
TTL-based cache with early renewal.
- High availability / load balancing / hot key rotation
- TLS termination
- Rate limiting / per-identity quotas
## Both sides need the right build tags
Sidecar is split into **two separate binaries** with **different build tags**:
| Side | Binary | Build tag | How to build |
| --- | --- | --- | --- |
| Sandbox (client) | `lark-cli` | `authsidecar` | `go build -tags authsidecar -o lark-cli .` |
| Trusted (server) | `sidecar-server-demo` | `authsidecar_demo` | `go build -tags authsidecar_demo -o sidecar-server-demo ./sidecar/server-demo/` |
If the sandbox runs a standard `lark-cli` **without** `-tags authsidecar`, the
`LARKSUITE_CLI_AUTH_PROXY` env var is ignored and requests bypass the sidecar
entirely — real credentials (if any) leak to the sandbox.
## Prerequisites
The demo reuses the lark-cli credential pipeline, so the trusted machine must
have an app configured:
```bash
lark-cli config init --new # configure app_id / app_secret (required)
lark-cli auth login # store user refresh_token in keychain
# (only required if sandbox will use --as user)
```
`auth login` is **only required for user identity**. If the server will only
serve bot requests (TAT), `config init` alone is enough because the TAT is
minted from `app_id + app_secret`.
Also, the server process **must not** inherit `LARKSUITE_CLI_AUTH_PROXY` — if
it does, the sidecar credential provider would activate inside the server and
return sentinel tokens instead of real ones. The demo rejects this at startup
with a clear error, but you should make sure to `unset LARKSUITE_CLI_AUTH_PROXY`
in the server shell before launching.
## Run
```bash
./sidecar-server-demo \
--listen 127.0.0.1:16384 \
--key-file <HOME>/.lark-sidecar/proxy.key \
--log-file <HOME>/.lark-sidecar/audit.log
```
### Flags
| Flag | Default | Purpose |
| --- | --- | --- |
| `--listen` | `127.0.0.1:16384` | Address to bind the HTTP listener |
| `--key-file` | `<HOME>/.lark-sidecar/proxy.key` | Path to write the generated HMAC key (mode 0600) |
| `--log-file` | *(empty, stderr)* | Audit log output path |
| `--profile` | *(empty, active profile)* | lark-cli profile name for credential lookup |
### Startup output
```
Auth sidecar listening on http://127.0.0.1:16384
HMAC key prefix: a3b2c1d4
Full key written to /Users/alice/.lark-sidecar/proxy.key (mode 0600)
Set in sandbox:
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
export LARKSUITE_CLI_PROXY_KEY="<read from /Users/alice/.lark-sidecar/proxy.key>"
export LARKSUITE_CLI_APP_ID="cli_xxx"
export LARKSUITE_CLI_BRAND="feishu"
```
The `key-file` path is printed exactly as passed on the command line (relative
paths stay relative). The `HMAC key prefix` is the first 8 characters for
identification without revealing the full key.
### Sandbox env vars (complete list)
The startup banner only prints the *required* variables. Two more are
optional:
```bash
export LARKSUITE_CLI_AUTH_PROXY="http://..." # required (see constraints below)
export LARKSUITE_CLI_PROXY_KEY="..." # required
export LARKSUITE_CLI_APP_ID="cli_xxx" # required
export LARKSUITE_CLI_BRAND="feishu" # required (feishu | lark)
export LARKSUITE_CLI_DEFAULT_AS="user" # optional: force default identity
export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to one identity
```
**`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup:
- Scheme must be `http://` (or bare `host:port`). `https://` is rejected
today because the interceptor does not yet perform TLS; a future PR that
wires up real TLS will relax this.
- Host must be loopback (`127.0.0.1`, `::1`) or one of the recognized
same-host aliases: `localhost`, `host.docker.internal`,
`host.containers.internal`, `host.lima.internal`, `gateway.docker.internal`.
The sidecar pattern is inherently same-machine; cross-machine deployment
is a different product (auth broker / STS) with different security
requirements (mTLS, cert rotation, per-client keys) and is not supported
by this feature.
- No path, query, fragment, or `user:pass@` in the URL.
**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
`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`;
if it fails (trusted side has no valid user login, or the call errors out),
it falls back to `bot`. Setting `LARKSUITE_CLI_DEFAULT_AS=user` lets you
short-circuit this and always default to user regardless of the lookup
result; set it to `bot` for the opposite.
**Note**: `LARKSUITE_CLI_STRICT_MODE` and the server's identity allowlist are
two separate enforcement points:
- `STRICT_MODE` is interpreted locally by the sandbox CLI — it rejects
`--as` values the sandbox itself disallows, before any request goes out.
- The server's allowlist is built from the **trusted-side** config's
`SupportedIdentities` (`sidecar/server-demo/allowlist.go`). The sandbox
cannot override it.
A well-configured deployment aligns both (e.g. both set to `user` when the
app only supports user tokens), but they are computed independently.
### Graceful shutdown
Send `SIGINT` (`Ctrl+C`) or `SIGTERM` to stop the server. The demo drains
in-flight requests with a 5-second timeout before exiting.
## Wire protocol
See the [`sidecar` package on pkg.go.dev](https://pkg.go.dev/github.com/larksuite/cli/sidecar)
for protocol constants, HMAC signing/verification, and address validation utilities.
Headers (client → server):
| Header | Purpose |
| --- | --- |
| `X-Lark-Proxy-Version` | Wire-protocol version (currently `"v1"`). Server rejects unknown values with 400. |
| `X-Lark-Proxy-Target` | Original target **scheme + host only** (e.g. `https://open.feishu.cn`). Must be `https://`; any path/query/fragment/userinfo in this header is rejected. The path and query come from the request line itself; the server reconstructs the upstream URL as `https://<host> + requestURI`. |
| `X-Lark-Proxy-Identity` | `"user"` or `"bot"`. Covered by the signature. |
| `X-Lark-Proxy-Auth-Header` | Which header the server should inject real token into. Covered by the signature. |
| `X-Lark-Proxy-Signature` | hex-encoded HMAC-SHA256 |
| `X-Lark-Proxy-Timestamp` | Unix seconds (drift ≤ 60s) |
| `X-Lark-Body-SHA256` | hex-encoded SHA-256 of the request body |
Signing material (newline-separated, in order):
```text
version
method
host
pathAndQuery
bodySHA256
timestamp
identity
authHeader
```
Every field above is part of the canonical string. In particular, `identity`
and `authHeader` are covered so a captured request cannot be replayed with
its identity flipped (bot↔user) or its auth-header redirected (e.g. into
`Cookie`) inside the 60s drift window.
## Source layout
| File | Purpose |
| --- | --- |
| `main.go` | Entry point: flag parsing, server lifecycle |
| `handler.go` | `proxyHandler.ServeHTTP` — main request flow |
| `forward.go` | Forwarding HTTP client + proxy-header filter |
| `allowlist.go` | Target host / identity allowlists |
| `audit.go` | Log path/error sanitization |
| `handler_test.go` | Unit tests for all of the above |

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
package main
import (
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/sidecar"
)
// buildAllowedHosts extracts the set of allowed target hostnames from
// multiple brand endpoints so the sidecar can serve both feishu and lark clients.
func buildAllowedHosts(endpoints ...core.Endpoints) map[string]bool {
hosts := make(map[string]bool)
for _, ep := range endpoints {
for _, u := range []string{ep.Open, ep.Accounts, ep.MCP} {
if idx := strings.Index(u, "://"); idx >= 0 {
hosts[u[idx+3:]] = true
}
}
}
return hosts
}
// buildAllowedIdentities returns the set of identities the sidecar is allowed to serve,
// based on the trusted-side strict mode / SupportedIdentities configuration.
func buildAllowedIdentities(cfg *core.CliConfig) map[string]bool {
ids := make(map[string]bool)
switch {
case cfg.SupportedIdentities == 0: // unknown/unset → allow both
ids[sidecar.IdentityUser] = true
ids[sidecar.IdentityBot] = true
case cfg.SupportedIdentities&1 != 0: // SupportsUser bit
ids[sidecar.IdentityUser] = true
}
if cfg.SupportedIdentities == 0 || cfg.SupportedIdentities&2 != 0 { // SupportsBot bit
ids[sidecar.IdentityBot] = true
}
return ids
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
package main
import "strings"
// sanitizePath strips query parameters and replaces ID-like path segments
// with ":id" to prevent document tokens, chat IDs, etc. from leaking into logs.
// Example: /open-apis/docx/v1/documents/doxcnXXXX/blocks → /open-apis/docx/v1/documents/:id/blocks
func sanitizePath(pathAndQuery string) string {
// Strip query
path := pathAndQuery
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
// Replace ID-like segments (8+ chars, not a pure API keyword)
parts := strings.Split(path, "/")
for i, p := range parts {
if looksLikeID(p) {
parts[i] = ":id"
}
}
return strings.Join(parts, "/")
}
// looksLikeID returns true if a path segment appears to be a resource identifier
// rather than an API route keyword. Heuristic: 8+ chars and contains a digit.
func looksLikeID(seg string) bool {
if len(seg) < 8 {
return false
}
for _, c := range seg {
if c >= '0' && c <= '9' {
return true
}
}
return false
}
// sanitizeError returns a safe error string for logging, capped at 200 bytes
// to avoid dumping upstream response bodies into audit logs.
func sanitizeError(err error) string {
s := err.Error()
if len(s) > 200 {
return s[:200] + "..."
}
return s
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
package main
import (
"fmt"
"net/http"
"time"
"github.com/larksuite/cli/sidecar"
)
// newForwardClient creates an HTTP client for forwarding requests to the
// Lark API. It strips Authorization on cross-host redirects and disables
// proxy to prevent real tokens from leaking through environment proxies.
func newForwardClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil // never proxy the trusted hop
return &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
req.Header.Del("Authorization")
req.Header.Del(sidecar.HeaderMCPUAT)
req.Header.Del(sidecar.HeaderMCPTAT)
}
return nil
},
}
}
// isProxyHeader returns true for headers specific to the sidecar protocol.
func isProxyHeader(key string) bool {
switch http.CanonicalHeaderKey(key) {
case http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
return true
}
return false
}

View File

@@ -0,0 +1,271 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
package main
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/url"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/sidecar"
)
// proxyHandler handles HTTP requests from sandbox CLI instances.
type proxyHandler struct {
key []byte
cred *credential.CredentialProvider
appID string
brand core.LarkBrand
logger *log.Logger
forwardCl *http.Client
allowedHosts map[string]bool // target host allowlist derived from brand
allowedIDs map[string]bool // identity allowlist derived from strict mode
}
// allowedAuthHeaders lists the only header names the sidecar will inject real
// tokens into. Limiting this prevents a compromised sandbox from signing a
// request with X-Lark-Proxy-Auth-Header: Cookie (or User-Agent /
// X-Forwarded-For / any X-* header) and having the real token smuggled into
// an upstream header that Lark ignores for auth but intermediate logs may
// capture — an indirect exfiltration path.
//
// These three are the only values the CLI interceptor ever emits
// (Authorization for OpenAPI, MCP-UAT/TAT for the MCP protocol), so anything
// else is by definition a misuse.
var allowedAuthHeaders = map[string]bool{
"Authorization": true,
sidecar.HeaderMCPUAT: true, // X-Lark-MCP-UAT
sidecar.HeaderMCPTAT: true, // X-Lark-MCP-TAT
}
func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 0. Check protocol version. We reject rather than default so that an
// old client paired with a newer server (or vice versa) fails loudly
// instead of silently producing mismatched signatures.
version := r.Header.Get(sidecar.HeaderProxyVersion)
if version != sidecar.ProtocolV1 {
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
return
}
// 1. Verify timestamp
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
if ts == "" {
http.Error(w, "missing "+sidecar.HeaderProxyTimestamp, http.StatusBadRequest)
return
}
// 2. Read body and verify SHA256
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}
r.Body.Close()
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
if claimedSHA == "" {
http.Error(w, "missing "+sidecar.HeaderBodySHA256, http.StatusBadRequest)
return
}
actualSHA := sidecar.BodySHA256(body)
if claimedSHA != actualSHA {
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
return
}
// 3. Verify HMAC signature
//Enforce scheme=https and reject any path/query embedded in the target.
// The sandbox is untrusted: without this check it could send
// X-Lark-Proxy-Target: http://open.feishu.cn to force the injected real
// token out over cleartext HTTP, exposing it to any on-path attacker
// between the sidecar and upstream.
target := r.Header.Get(sidecar.HeaderProxyTarget)
if target == "" {
http.Error(w, "missing "+sidecar.HeaderProxyTarget, http.StatusBadRequest)
return
}
pathAndQuery := r.URL.RequestURI()
targetHost, err := parseTarget(target)
if err != nil {
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+err.Error(), http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
return
}
// Identity and auth-header must be read before HMAC verification because
// both are covered by the canonical signing string. Defaulting either one
// server-side would let an attacker flip the injected token's identity or
// target header within the replay window without invalidating the sig.
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
if identity == "" {
http.Error(w, "missing "+sidecar.HeaderProxyIdentity, http.StatusBadRequest)
return
}
authHeader := r.Header.Get(sidecar.HeaderProxyAuthHeader)
if authHeader == "" {
http.Error(w, "missing "+sidecar.HeaderProxyAuthHeader, http.StatusBadRequest)
return
}
signature := r.Header.Get(sidecar.HeaderProxySignature)
if err := sidecar.Verify(h.key, sidecar.CanonicalRequest{
Version: version,
Method: r.Method,
Host: targetHost,
PathAndQuery: pathAndQuery,
BodySHA256: claimedSHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
}, signature); err != nil {
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
return
}
// 4. Validate target host against allowlist
if !h.allowedHosts[targetHost] {
http.Error(w, "target host not allowed: "+targetHost, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"target host %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), targetHost)
return
}
// 5. Validate identity
if !h.allowedIDs[identity] {
http.Error(w, "identity not allowed: "+identity, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"identity %s not allowed by strict mode\"", r.Method, sanitizePath(pathAndQuery), identity)
return
}
// 5.5 Validate auth-header (required — the client controls this value,
// and without an allowlist a compromised sandbox could direct the real
// token into arbitrary forwarded headers).
if !allowedAuthHeaders[authHeader] {
http.Error(w, "auth-header not allowed: "+authHeader, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"auth-header %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), authHeader)
return
}
// 6. Resolve real token
var tokenType credential.TokenType
switch identity {
case sidecar.IdentityUser:
tokenType = credential.TokenTypeUAT
default:
tokenType = credential.TokenTypeTAT
}
tokenResult, err := h.cred.ResolveToken(r.Context(), credential.TokenSpec{
Type: tokenType,
AppID: h.appID,
})
if err != nil {
http.Error(w, "failed to resolve token: "+err.Error(), http.StatusInternalServerError)
h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s error=%q", r.Method, sanitizePath(pathAndQuery), identity, sanitizeError(err))
return
}
// 7. Build forwarding request. Scheme is pinned to https here (not taken
// from the client-supplied target) so any future change to parseTarget
// cannot regress the cleartext-leak protection.
forwardURL := "https://" + targetHost + pathAndQuery
forwardReq, err := http.NewRequestWithContext(r.Context(), r.Method, forwardURL, bytes.NewReader(body))
if err != nil {
http.Error(w, "failed to create forward request", http.StatusInternalServerError)
return
}
// Copy non-proxy headers
for k, vs := range r.Header {
if isProxyHeader(k) {
continue
}
for _, v := range vs {
forwardReq.Header.Add(k, v)
}
}
// Strip any client-supplied auth headers. The sidecar is the sole source
// of authentication material on the forwarded request; a client could
// otherwise smuggle an extra Authorization/MCP token alongside the one
// the sidecar injects below.
forwardReq.Header.Del("Authorization")
forwardReq.Header.Del(sidecar.HeaderMCPUAT)
forwardReq.Header.Del(sidecar.HeaderMCPTAT)
// 8. Inject real token into the header the client committed to in the
// signature. Standard OpenAPI uses "Authorization: Bearer <token>"; MCP
// uses "X-Lark-MCP-UAT: <token>" or "X-Lark-MCP-TAT: <token>".
if authHeader == "Authorization" {
forwardReq.Header.Set("Authorization", "Bearer "+tokenResult.Token)
} else {
forwardReq.Header.Set(authHeader, tokenResult.Token)
}
// 9. Forward request
resp, err := h.forwardCl.Do(forwardReq)
if err != nil {
http.Error(w, "forward request failed: "+err.Error(), http.StatusBadGateway)
h.logger.Printf("FORWARD_ERROR method=%s path=%s error=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
return
}
defer resp.Body.Close()
// 10. Copy response back
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
// 11. Audit log
h.logger.Printf("FORWARD method=%s path=%s identity=%s status=%d duration=%s",
r.Method, sanitizePath(pathAndQuery), identity, resp.StatusCode, time.Since(start).Round(time.Millisecond))
}
// parseTarget validates X-Lark-Proxy-Target and returns the host portion for
// HMAC input and allowlist lookup. The target must be "https://<host>" with no
// path, query, fragment, userinfo, or non-https scheme. Rejecting these shapes
// closes a token-leak channel: a compromised sandbox holding PROXY_KEY could
// otherwise request cleartext HTTP forwarding (or inject a path to a different
// endpoint than the allowlist entry implies).
func parseTarget(target string) (host string, err error) {
u, perr := url.Parse(target)
if perr != nil {
return "", fmt.Errorf("parse: %w", perr)
}
if u.Scheme != "https" {
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("missing host")
}
if u.User != nil {
return "", fmt.Errorf("userinfo not allowed")
}
if u.Path != "" && u.Path != "/" {
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
}
if u.RawQuery != "" {
return "", fmt.Errorf("query not allowed")
}
if u.Fragment != "" {
return "", fmt.Errorf("fragment not allowed")
}
return u.Host, nil
}

View File

@@ -0,0 +1,670 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// fakeExtProvider is a stub extcred.Provider for tests that returns a fixed token.
type fakeExtProvider struct {
token string
}
func (f *fakeExtProvider) Name() string { return "fake" }
func (f *fakeExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
return nil, nil
}
func (f *fakeExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
return &extcred.Token{Value: f.token, Source: "fake"}, nil
}
func discardLogger() *log.Logger {
return log.New(io.Discard, "", 0)
}
func newTestHandler(key []byte) *proxyHandler {
return &proxyHandler{
key: key,
logger: discardLogger(),
forwardCl: &http.Client{},
allowedHosts: map[string]bool{
"open.feishu.cn": true,
"accounts.feishu.cn": true,
"mcp.feishu.cn": true,
},
allowedIDs: map[string]bool{
sidecar.IdentityUser: true,
sidecar.IdentityBot: true,
},
}
}
// signedReq creates a properly signed request for testing handler logic past
// HMAC verification. Identity defaults to bot and auth-header to
// "Authorization"; callers can override by mutating the returned request
// before calling ServeHTTP (and re-signing if they need the signature to
// remain valid after the mutation).
func signedReq(t *testing.T, key []byte, method, target, path string, body []byte) *http.Request {
t.Helper()
targetHost := target
if idx := strings.Index(target, "://"); idx >= 0 {
targetHost = target[idx+3:]
}
bodySHA := sidecar.BodySHA256(body)
ts := sidecar.Timestamp()
identity := sidecar.IdentityBot
authHeader := "Authorization"
sig := sidecar.Sign(key, sidecar.CanonicalRequest{
Version: sidecar.ProtocolV1,
Method: method,
Host: targetHost,
PathAndQuery: path,
BodySHA256: bodySHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
})
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req := httptest.NewRequest(method, path, bodyReader)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTarget, target)
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderProxySignature, sig)
return req
}
// resign recomputes the HMAC signature over the request's current proxy
// headers. Use this in tests that mutate a signed field (Identity,
// AuthHeader, Target host, etc.) after calling signedReq.
func resign(t *testing.T, key []byte, req *http.Request, body []byte) {
t.Helper()
target := req.Header.Get(sidecar.HeaderProxyTarget)
targetHost := target
if idx := strings.Index(target, "://"); idx >= 0 {
targetHost = target[idx+3:]
}
sig := sidecar.Sign(key, sidecar.CanonicalRequest{
Version: req.Header.Get(sidecar.HeaderProxyVersion),
Method: req.Method,
Host: targetHost,
PathAndQuery: req.URL.RequestURI(),
BodySHA256: sidecar.BodySHA256(body),
Timestamp: req.Header.Get(sidecar.HeaderProxyTimestamp),
Identity: req.Header.Get(sidecar.HeaderProxyIdentity),
AuthHeader: req.Header.Get(sidecar.HeaderProxyAuthHeader),
})
req.Header.Set(sidecar.HeaderProxySignature, sig)
}
// TestProxyHandler_UnsupportedVersion verifies the handler rejects requests
// whose HeaderProxyVersion is absent or set to an unknown value. Kept in
// front so an old client paired with a newer server (or vice versa) surfaces
// a clear 400 instead of a misleading HMAC mismatch downstream.
func TestProxyHandler_UnsupportedVersion(t *testing.T) {
h := newTestHandler([]byte("key"))
for _, v := range []string{"", "v0", "v2"} {
req := httptest.NewRequest("GET", "/path", nil)
if v != "" {
req.Header.Set(sidecar.HeaderProxyVersion, v)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("version=%q: expected 400, got %d", v, w.Code)
}
}
}
func TestProxyHandler_MissingTimestamp(t *testing.T) {
h := newTestHandler([]byte("key"))
req := httptest.NewRequest("GET", "/path", nil)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestProxyHandler_MissingBodySHA(t *testing.T) {
h := newTestHandler([]byte("key"))
req := httptest.NewRequest("GET", "/path", nil)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp())
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestProxyHandler_BadHMAC(t *testing.T) {
h := newTestHandler([]byte("real-key"))
bodySHA := sidecar.BodySHA256(nil)
ts := sidecar.Timestamp()
req := httptest.NewRequest("GET", "/path", nil)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn")
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityBot)
req.Header.Set(sidecar.HeaderProxyAuthHeader, "Authorization")
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
req.Header.Set(sidecar.HeaderProxySignature, "bad-signature")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestProxyHandler_BodySHA256Mismatch(t *testing.T) {
h := newTestHandler([]byte("key"))
req := httptest.NewRequest("POST", "/path", bytes.NewReader([]byte("real body")))
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn")
req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp())
req.Header.Set(sidecar.HeaderBodySHA256, sidecar.BodySHA256([]byte("different body")))
req.Header.Set(sidecar.HeaderProxySignature, "whatever")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestProxyHandler_TargetNotAllowed(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
req := signedReq(t, key, "GET", "https://evil.com", "/steal", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for disallowed host, got %d", w.Code)
}
}
func TestProxyHandler_IdentityNotAllowed(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
// Restrict to bot only
h.allowedIDs = map[string]bool{sidecar.IdentityBot: true}
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser)
resign(t, key, req, nil) // identity is signed; must re-sign after mutation
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for disallowed identity, got %d", w.Code)
}
}
// TestParseTarget covers the per-shape rejections directly, without the
// surrounding HTTP plumbing.
func TestParseTarget(t *testing.T) {
cases := []struct {
name string
target string
wantErr bool
wantSub string // expected fragment of the error message
}{
{name: "valid https", target: "https://open.feishu.cn", wantErr: false},
{name: "valid https trailing slash", target: "https://open.feishu.cn/", wantErr: false},
{name: "http downgrade", target: "http://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
{name: "missing scheme", target: "open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
{name: "ftp scheme", target: "ftp://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
{name: "empty", target: "", wantErr: true, wantSub: "scheme must be https"},
{name: "empty host", target: "https://", wantErr: true, wantSub: "missing host"},
{name: "with path", target: "https://open.feishu.cn/open-apis", wantErr: true, wantSub: "path not allowed"},
{name: "with query", target: "https://open.feishu.cn?a=1", wantErr: true, wantSub: "query not allowed"},
{name: "with fragment", target: "https://open.feishu.cn#frag", wantErr: true, wantSub: "fragment not allowed"},
{name: "with userinfo", target: "https://attacker:pw@open.feishu.cn", wantErr: true, wantSub: "userinfo not allowed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
host, err := parseTarget(tc.target)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got host=%q", host)
}
if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) {
t.Errorf("error %q should contain %q", err.Error(), tc.wantSub)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "open.feishu.cn" {
t.Errorf("host = %q, want %q", host, "open.feishu.cn")
}
})
}
}
// TestProxyHandler_RejectsNonHTTPSTarget verifies end-to-end that a
// compromised sandbox holding a valid PROXY_KEY cannot coerce the sidecar
// into forwarding real tokens over cleartext HTTP or to an unexpected path.
// The check must fire before HMAC verification so that the request is
// rejected even when the signature is technically valid.
func TestProxyHandler_RejectsNonHTTPSTarget(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
cases := []struct {
name string
target string
}{
{"http downgrade", "http://open.feishu.cn"},
{"bare hostname", "open.feishu.cn"},
{"ftp scheme", "ftp://open.feishu.cn"},
{"target with path", "https://open.feishu.cn/open-apis/evil"},
{"target with query", "https://open.feishu.cn?steal=1"},
{"target with userinfo", "https://attacker:pw@open.feishu.cn"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Sign with a valid key against the malicious target — proves the
// scheme/shape check is not bypassed by signature legitimacy.
req := signedReq(t, key, "GET", tc.target, "/open-apis/im/v1/chats", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for target %q, got %d (body: %s)", tc.target, w.Code, w.Body.String())
}
})
}
}
// TestProxyHandler_RejectsIdentityReplay locks in C1 end-to-end: a captured
// bot-signed request whose identity header is flipped to user (or vice versa)
// must be rejected at HMAC verification, not silently served with the wrong
// token type. Without identity in the canonical string this returns 200.
func TestProxyHandler_RejectsIdentityReplay(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
// Attacker flips identity without touching signature.
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("identity replay must fail signature verify (got %d, want 401): %s",
w.Code, w.Body.String())
}
}
// TestProxyHandler_RejectsAuthHeaderReplay is the companion: flipping
// X-Lark-Proxy-Auth-Header post-signature must invalidate the signature so
// an attacker cannot redirect the injected token into an unintended header.
func TestProxyHandler_RejectsAuthHeaderReplay(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, "Cookie")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("auth-header replay must fail signature verify (got %d, want 401): %s",
w.Code, w.Body.String())
}
}
// TestProxyHandler_RejectsAuthHeaderNotInAllowlist pins the auth-header
// allowlist: even a correctly-signed request must be rejected if it asks
// the sidecar to inject the real token into an unintended header (e.g.
// Cookie / User-Agent / X-Forwarded-For). This closes the sidechannel
// where the real token ends up in headers that Lark ignores for auth but
// intermediate logs may capture.
func TestProxyHandler_RejectsAuthHeaderNotInAllowlist(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
for _, bad := range []string{"Cookie", "User-Agent", "X-Forwarded-For", "X-Real-IP", "Set-Cookie"} {
t.Run(bad, func(t *testing.T) {
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, bad)
resign(t, key, req, nil) // auth-header is signed; must re-sign after override
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("authHeader=%q: expected 403, got %d (body: %s)",
bad, w.Code, w.Body.String())
}
})
}
}
// TestProxyHandler_AcceptsAllowedAuthHeaders confirms the three protocol
// header names remain accepted after the allowlist is enforced. Uses
// newTestHandler which has no upstream forwarding set up, so reaching the
// forward step is proof the auth-header check passed.
func TestProxyHandler_AcceptsAllowedAuthHeaders(t *testing.T) {
key := []byte("test-key")
for _, good := range []string{"Authorization", sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT} {
t.Run(good, func(t *testing.T) {
// Use a handler with a real (fake) credential provider so we can
// distinguish auth-header reject (403) from later failures.
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{token: "real-token"}},
nil, nil, nil,
)
h := &proxyHandler{
key: key,
cred: cred,
appID: "cli_test",
logger: discardLogger(),
forwardCl: &http.Client{},
allowedHosts: map[string]bool{"open.feishu.cn": true},
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
}
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, good)
resign(t, key, req, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
// Expect NOT 403 "auth-header not allowed" — the request will fail
// at forward (502 because open.feishu.cn isn't reachable without
// an actual upstream in tests), but it must get past our check.
if w.Code == http.StatusForbidden && strings.Contains(w.Body.String(), "auth-header not allowed") {
t.Errorf("authHeader=%q was rejected by allowlist: %s", good, w.Body.String())
}
})
}
}
func TestRun_RejectsSelfProxy(t *testing.T) {
old, had := os.LookupEnv(envvars.CliAuthProxy)
os.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
defer func() {
if had {
os.Setenv(envvars.CliAuthProxy, old)
} else {
os.Unsetenv(envvars.CliAuthProxy)
}
}()
err := run(context.Background(), "127.0.0.1:0", "/tmp/should-not-be-created.key", "", "")
if err == nil {
t.Fatal("expected error when AUTH_PROXY is set")
}
if !strings.Contains(err.Error(), envvars.CliAuthProxy) {
t.Errorf("error should mention %s, got: %v", envvars.CliAuthProxy, err)
}
}
func TestForwardClient_RedirectStripsAuth(t *testing.T) {
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if auth := r.Header.Get("Authorization"); auth != "" {
t.Errorf("Authorization leaked to redirect target: %s", auth)
}
w.WriteHeader(http.StatusOK)
}))
defer redirectTarget.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound)
}))
defer origin.Close()
client := newForwardClient()
req, _ := http.NewRequest("GET", origin.URL+"/start", nil)
req.Header.Set("Authorization", "Bearer real-token")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
}
func TestForwardClient_RedirectStripsMCPHeaders(t *testing.T) {
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if v := r.Header.Get(sidecar.HeaderMCPUAT); v != "" {
t.Errorf("X-Lark-MCP-UAT leaked to redirect target: %s", v)
}
if v := r.Header.Get(sidecar.HeaderMCPTAT); v != "" {
t.Errorf("X-Lark-MCP-TAT leaked to redirect target: %s", v)
}
w.WriteHeader(http.StatusOK)
}))
defer redirectTarget.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound)
}))
defer origin.Close()
client := newForwardClient()
req, _ := http.NewRequest("POST", origin.URL+"/mcp", nil)
req.Header.Set(sidecar.HeaderMCPUAT, "real-uat-token")
req.Header.Set(sidecar.HeaderMCPTAT, "real-tat-token")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
}
// TestProxyHandler_StripsClientSuppliedAuthHeaders verifies that the sidecar
// is the sole source of auth headers on the forwarded request. A malicious
// sandbox client must not be able to smuggle an Authorization/MCP header that
// rides along with the sidecar-injected real token.
func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
const realToken = "real-tenant-access-token"
// Capture what the upstream receives after sidecar forwarding.
// TLS is required because parseTarget rejects non-https targets.
var captured http.Header
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
// Strip "https://" prefix to get host:port (matches what the handler sees).
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{token: realToken}},
nil, nil, nil,
)
key := []byte("test-key")
h := &proxyHandler{
key: key,
cred: cred,
appID: "cli_test",
logger: discardLogger(),
forwardCl: upstream.Client(), // trusts the httptest CA
allowedHosts: map[string]bool{upstreamHost: true},
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
}
cases := []struct {
name string
proxyAuthHeader string // which header sidecar should inject into
wantInjectedHeader string // the header the real token ends up in
wantInjectedValue string
wantStrippedHeaders []string
}{
{
name: "inject Authorization, strip MCP attacker headers",
proxyAuthHeader: "Authorization",
wantInjectedHeader: "Authorization",
wantInjectedValue: "Bearer " + realToken,
wantStrippedHeaders: []string{sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT},
},
{
name: "inject MCP UAT, strip Authorization attacker header",
proxyAuthHeader: sidecar.HeaderMCPUAT,
wantInjectedHeader: sidecar.HeaderMCPUAT,
wantInjectedValue: realToken,
wantStrippedHeaders: []string{"Authorization", sidecar.HeaderMCPTAT},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
captured = nil
req := signedReq(t, key, "GET", "https://"+upstreamHost, "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, tc.proxyAuthHeader)
resign(t, key, req, nil) // auth-header is signed; re-sign after override
// Attacker smuggles all three possible auth headers with bogus values.
req.Header.Set("Authorization", "Bearer attacker-token")
req.Header.Set(sidecar.HeaderMCPUAT, "attacker-uat")
req.Header.Set(sidecar.HeaderMCPTAT, "attacker-tat")
// Non-auth headers should still pass through.
req.Header.Set("X-Custom-Header", "keep-me")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from upstream, got %d; body=%s", w.Code, w.Body.String())
}
if captured == nil {
t.Fatal("upstream handler was not invoked")
}
// Injected header contains the real token (not the attacker value).
if got := captured.Get(tc.wantInjectedHeader); got != tc.wantInjectedValue {
t.Errorf("%s = %q, want %q", tc.wantInjectedHeader, got, tc.wantInjectedValue)
}
// All other auth headers must be stripped.
for _, h := range tc.wantStrippedHeaders {
if got := captured.Get(h); got != "" {
t.Errorf("%s should be stripped, got %q", h, got)
}
}
// Non-auth headers still forwarded.
if got := captured.Get("X-Custom-Header"); got != "keep-me" {
t.Errorf("X-Custom-Header = %q, want %q", got, "keep-me")
}
})
}
}
func TestBuildAllowedHosts(t *testing.T) {
feishu := struct{ Open, Accounts, MCP string }{
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
}
lark := struct{ Open, Accounts, MCP string }{
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
}
hosts := buildAllowedHosts(feishu, lark)
// feishu hosts
if !hosts["open.feishu.cn"] {
t.Error("expected open.feishu.cn in allowlist")
}
if !hosts["mcp.feishu.cn"] {
t.Error("expected mcp.feishu.cn in allowlist")
}
// lark hosts
if !hosts["open.larksuite.com"] {
t.Error("expected open.larksuite.com in allowlist")
}
if !hosts["mcp.larksuite.com"] {
t.Error("expected mcp.larksuite.com in allowlist")
}
// evil host
if hosts["evil.com"] {
t.Error("evil.com should not be in allowlist")
}
}
func TestSanitizePath(t *testing.T) {
tests := []struct {
input string
want string
}{
{"/open-apis/im/v1/messages?receive_id_type=chat_id", "/open-apis/im/v1/messages"},
{"/open-apis/calendar/v4/events", "/open-apis/calendar/v4/events"},
{"/open-apis/docx/v1/documents/doxcnABCD1234/blocks", "/open-apis/docx/v1/documents/:id/blocks"},
{"/open-apis/im/v1/chats/oc_abcdef12345678/members", "/open-apis/im/v1/chats/:id/members"},
{"/path?secret=abc", "/path"},
}
for _, tt := range tests {
if got := sanitizePath(tt.input); got != tt.want {
t.Errorf("sanitizePath(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestLooksLikeID(t *testing.T) {
tests := []struct {
seg string
want bool
}{
{"doxcnABCD1234", true}, // doc token
{"oc_abcdef12345678", true}, // chat ID
{"v1", false}, // API version
{"messages", false}, // route keyword
{"open-apis", false}, // route prefix
{"ab1", false}, // too short
}
for _, tt := range tests {
if got := looksLikeID(tt.seg); got != tt.want {
t.Errorf("looksLikeID(%q) = %v, want %v", tt.seg, got, tt.want)
}
}
}
func TestSanitizeError(t *testing.T) {
short := fmt.Errorf("short error")
if got := sanitizeError(short); got != "short error" {
t.Errorf("got %q", got)
}
longMsg := make([]byte, 300)
for i := range longMsg {
longMsg[i] = 'x'
}
long := fmt.Errorf("%s", string(longMsg))
got := sanitizeError(long)
if len(got) > 210 {
t.Errorf("expected truncation, got %d chars", len(got))
}
if !bytes.HasSuffix([]byte(got), []byte("...")) {
t.Errorf("expected '...' suffix, got %q", got[len(got)-10:])
}
}

167
sidecar/server-demo/main.go Normal file
View File

@@ -0,0 +1,167 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_demo
// Command sidecar-server-demo is a reference implementation of a sidecar
// auth proxy server. It is NOT production-ready — integrators should
// implement their own server conforming to the wire protocol defined in
// github.com/larksuite/cli/sidecar.
//
// The demo reuses the lark-cli credential pipeline (keychain + config) to
// resolve real tokens, so it only works on a machine that has been
// configured with `lark-cli auth login`.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/sidecar"
)
func main() {
listen := flag.String("listen", sidecar.DefaultListenAddr, "listen address (host:port)")
keyFile := flag.String("key-file", defaultKeyFile(), "path to write the HMAC key")
logFile := flag.String("log-file", "", "audit log file (stderr if empty)")
profile := flag.String("profile", "", "lark-cli profile name (empty = active profile)")
flag.Parse()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := run(ctx, *listen, *keyFile, *logFile, *profile); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func defaultKeyFile() string {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".lark-sidecar", "proxy.key")
}
return "/tmp/lark-sidecar/proxy.key"
}
func run(ctx context.Context, listen, keyFile, logFile, profile string) error {
// Reject self-proxy: if this process inherited AUTH_PROXY, the sidecar
// credential provider would activate and return sentinel tokens instead
// of real ones, breaking the "trusted side holds real credentials" premise.
if v := os.Getenv(envvars.CliAuthProxy); v != "" {
return fmt.Errorf("%s is set in this environment (%s); unset it before starting the sidecar server", envvars.CliAuthProxy, v)
}
if listen == "" {
return fmt.Errorf("invalid --listen address: empty")
}
// Generate HMAC key (32 bytes = 256 bits) and write it to disk (0600).
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return fmt.Errorf("failed to generate HMAC key: %v", err)
}
keyHex := hex.EncodeToString(keyBytes)
keyDir := filepath.Dir(keyFile)
if err := vfs.MkdirAll(keyDir, 0700); err != nil {
return fmt.Errorf("failed to create key directory: %v", err)
}
if err := vfs.WriteFile(keyFile, []byte(keyHex), 0600); err != nil {
return fmt.Errorf("failed to write key file: %v", err)
}
// Audit logger: file or stderr.
var auditLogger *log.Logger
if logFile != "" {
f, err := vfs.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open log file: %v", err)
}
defer f.Close()
auditLogger = log.New(f, "", log.LstdFlags)
} else {
auditLogger = log.New(os.Stderr, "[audit] ", log.LstdFlags)
}
// Reuse the lark-cli credential pipeline. A production implementation
// would likely source credentials from a secrets manager instead.
factory := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
cfg, err := factory.Config()
if err != nil {
return fmt.Errorf("failed to load config: %v", err)
}
listener, err := net.Listen("tcp", listen)
if err != nil {
return fmt.Errorf("failed to listen on %s: %v", listen, err)
}
defer listener.Close()
allowedHosts := buildAllowedHosts(
core.ResolveEndpoints(core.BrandFeishu),
core.ResolveEndpoints(core.BrandLark),
)
allowedIDs := buildAllowedIdentities(cfg)
handler := &proxyHandler{
key: []byte(keyHex),
cred: factory.Credential,
appID: cfg.AppID,
brand: cfg.Brand,
logger: auditLogger,
forwardCl: newForwardClient(),
allowedHosts: allowedHosts,
allowedIDs: allowedIDs,
}
server := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
go func() {
<-ctx.Done()
auditLogger.Println("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
auditLogger.Printf("shutdown error: %v", err)
}
}()
keyPrefix := keyHex
if len(keyPrefix) > 8 {
keyPrefix = keyPrefix[:8]
}
proxyURL := "http://" + listen
fmt.Fprintf(os.Stderr, "Auth sidecar listening on %s\n", proxyURL)
fmt.Fprintf(os.Stderr, "HMAC key prefix: %s\n", keyPrefix)
fmt.Fprintf(os.Stderr, "Full key written to %s (mode 0600)\n", keyFile)
fmt.Fprintf(os.Stderr, "\nSet in sandbox:\n")
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAuthProxy, proxyURL)
fmt.Fprintf(os.Stderr, " export %s=\"<read from %s>\"\n", envvars.CliProxyKey, keyFile)
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAppID, cfg.AppID)
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliBrand, string(cfg.Brand))
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("sidecar server exited unexpectedly: %v", err)
}
return nil
}

View File

@@ -102,8 +102,8 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--selection-with-ellipsis` 或 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--selection-with-ellipsis` / `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
| 列出文档评论 | `file_token` | 同添加评论 |
@@ -111,8 +111,8 @@ Drive Folder (云空间文件夹)
### 评论能力边界(关键!)
- `drive +add-comment` 支持两种模式。
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --detail with-ids` 获取。
- 全文评论:未传 `--selection-with-ellipsis` / `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--selection-with-ellipsis` 或 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
- 如果 wiki 解析后不是 `doc`/`docx`,不要用 `+add-comment`。
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。

View File

@@ -193,7 +193,7 @@
| 字段 | 类型 | 说明 |
|------|------|----------------------------|
| `allow_edit` | bool | 可新增、删除、修改视图,未提及默认`false` |
| `allow_edit` | bool | 可新增、删除、修改视图;表权限为 `edit` 时默认为 `true`,表权限为 `read_only` 或用户明确限制时`false` |
| `visibility` | object | 可见的视图配置 |
| `visibility.all_visible` | bool | 是否全部可见 |
| `visibility.visible_views` | []string | 可见视图名称 列表 |
@@ -203,7 +203,7 @@
输出 `view_rule` 时,**必须**使用以下完整结构,根据场景选择对应模板:
```json
// 情况 A用户要求可编辑/新增/删除视图 → allow_edit 为 true
// 情况 A表权限为 edit 且用户未明确限制 → allow_edit 默认为 true,全部可见
{
"view_rule": {
"allow_edit": true,
@@ -213,7 +213,7 @@
}
}
// 情况 B用户未提及具体视图,未要求编辑视图 → 全部可见、不可编辑
// 情况 B表权限为 read_only或用户明确说不可编辑视图 → 全部可见、不可编辑
{
"view_rule": {
"allow_edit": false,
@@ -223,10 +223,10 @@
}
}
// 情况 C用户提及了具体视图 → 仅指定视图可见
// 情况 C用户提及了具体视图 → 仅指定视图可见allow_edit 仍按 A/B 规则判断)
{
"view_rule": {
"allow_edit": false,
"allow_edit": true,
"visibility": {
"all_visible": false,
"visible_views": ["表格视图", "看板视图"]
@@ -415,7 +415,15 @@
| 仪表盘访问 | 不配置 | 用户明确提及该仪表盘 |
| `base_rule_map.copy` | `false` | 用户明确要求"允许复制" |
| `base_rule_map.download` | `false` | 用户明确要求"允许下载/打印/副本" |
| `record_operations` 中的 `delete` | 不包含 | 用户明确说"允许删除"或使用强语义("完全管理""可删改" |
### 默认开启项(条件性)
以下能力在特定条件下**默认开启**,用户明确限制时才排除:
| 能力 | 默认值 | 排除条件 |
|------|--------|----------|
| `record_operations` 中的 `delete` | **包含**`perm = edit` 时) | 用户明确限制时才排除 |
| `view_rule.allow_edit` | **`true`**`perm = edit` 时) | 用户明确限制"不可编辑视图"或 `perm = read_only` 时设为 `false` |
---
@@ -436,7 +444,7 @@
### 记录操作默认策略
**注意**:
- 用户未提及时,默认包含 `add`,默认不包含 `delete`
- 用户未提及时,表权限为 `edit` 时默认同时包含 `add``delete`,默认不包含 `delete` 的情况仅适用于用户明确限制操作的场景
- 阅读范围默认对齐编辑范围:用户仅描述可编辑范围、未说明阅读范围时,可阅读范围与可编辑范围保持一致,不主动扩大
- 当可读范围与可编辑范围一致时,**不得**生成 `read_filter_rule_group`;应设置 `other_record_all_read = false``read_filter_rule_group = null`
@@ -475,7 +483,7 @@
1. **先判断用户是否提及了具体视图名称**(如"看板视图可见""甘特图不可编辑"等)
- **是** → `all_visible = false``visible_views` 仅包含用户明确提及为"可见"的视图名称(非 viewID未提及的视图视为不可见
- **否**(用户完全未提及任何视图)→ `all_visible = true`
2. `allow_edit` 默认为 `false`仅当用户明确要求"可编辑视图""可新增/删除视图""可管理视图"时才设为 `true`。设为 `true` 时仍**必须**包含 `visibility` 字段(参考视图权限 情况 A
2. `allow_edit` 在表权限为 `edit` 时**默认为 `true`**仅当用户明确限制"不可编辑视图"时才设为 `false`。设为 `true` 时仍**必须**包含 `visibility` 字段(参考视图权限 情况 A
3. `all_visible``false` 时,`visible_views` **不可为空**,必须至少包含一个视图
**❌ 常见错误 — 缺少 `visibility` 字段:**

View File

@@ -1,55 +1,127 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档:创建和编辑飞书文档。默认使用 DocxXML 格式(也支持 Markdown)。创建文档、获取文档内容(支持 simple/with-ids/full 三种导出详细度,以及 full/outline/range/keyword/section 五种局部读取模式可按目录、block id 区间、关键词或标题自动成节只拉部分内容以节省上下文、更新文档八种指令str_replace/str_delete/block_insert_after/block_replace/block_delete/block_move_after/overwrite/append)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
version: 1.0.0
description: "飞书云文档:创建和编辑飞书文档。 Markdown 创建文档、获取文档内容、更新文档(追加/覆盖/替换/插入/删除)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --help"
---
# docs (v2)
# docs (v1)
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create`、`docs +fetch`、`docs +update` 命令必须携带 `--api-version v2`。**
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## 核心概念
### 文档类型与 Token
飞书开放平台中,不同类型的文档有不同的 URL 格式和 Token 处理方式。在进行文档操作(如添加评论、下载文件等)时,必须先获取正确的 `file_token`
### 文档 URL 格式与 Token 处理
| URL 格式 | 示例 | Token 类型 | 处理方式 |
|----------|---------------------------------------------------------|-----------|----------|
| `/docx/` | `https://example.larksuite.com/docx/doxcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 |
### Wiki 链接特殊处理(关键!)
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。
#### 处理流程
1. **使用 `wiki.spaces.get_node` 查询节点信息**
```bash
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
```
2. **从返回结果中提取关键信息**
- `node.obj_type`文档类型docx/doc/sheet/bitable/slides/file/mindnote
- `node.obj_token`**真实的文档 token**(用于后续操作)
- `node.title`:文档标题
3. **根据 `obj_type` 使用对应的 API**
| obj_type | 说明 | 使用的 API |
|----------|------|-----------|
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
| `doc` | 旧版云文档 | `drive file.comments.*` |
| `sheet` | 电子表格 | `sheets.*` |
| `bitable` | 多维表格 | `bitable.*` |
| `slides` | 幻灯片 | `drive.*` |
| `file` | 文件 | `drive.*` |
| `mindnote` | 思维导图 | `drive.*` |
#### 查询示例
```bash
# 常用示例
lark-cli docs +fetch --api-version v2 --doc "文档URL或token"
lark-cli docs +create --api-version v2 --content '<title>标题</title><p>内容</p>'
lark-cli docs +update --api-version v2 --doc "文档URL或token" --command append --content '<p>内容</p>'
# 查询 wiki 节点
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
```
## 前置条件 — 执行操作前必读
返回结果示例:
```json
{
"node": {
"obj_type": "docx",
"obj_token": "xxxx",
"title": "标题",
"node_type": "origin",
"space_id": "12345678910"
}
}
```
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
### 资源关系
**未读完以上文件就执行相应操作会导致参数选择错误、格式错误或样式不达标。**
```
Wiki Space (知识空间)
└── Wiki Node (知识库节点)
├── obj_type: docx (新版文档)
│ └── obj_token (真实文档 token)
├── obj_type: doc (旧版文档)
│ └── obj_token (真实文档 token)
├── obj_type: sheet (电子表格)
│ └── obj_token (真实文档 token)
├── obj_type: bitable (多维表格)
│ └── obj_token (真实文档 token)
└── obj_type: file/slides/mindnote
└── obj_token (真实文档 token)
> **格式选择规则(全局):** `docs +create` 和 `docs +update` 始终使用 XML 格式(`--doc-format xml`,即默认值),除非用户明确要求使用 Markdown。XML 支持 callout、grid、checkbox 等丰富 block 类型——不要因为 Markdown 更简单就自行切换。
Drive Folder (云空间文件夹)
└── File (文件/文档)
└── file_token (直接使用)
```
## 绘图需求识别与挖掘
用户很少主动提"画板"——**默认**使用飞书画板承载图表,命中以下任一信号即触发:
- 用户提到图表类型:架构图、流程图、时序图、组织图、路线图、对比图、鱼骨图、飞轮图、思维导图等
- 用户表达可视化意图:画一下、梳理关系、画个流程、给我一个图、方便汇报等
- 文档主题涉及结构关系、流程走向、时间线、数据对比
以下场景不加图:用户明确拒绝、合同/法律条款/合规声明等严谨连续文本、原样转录任务。
> [!CAUTION]
> 命中后,**MUST** 先读取 [`references/lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 并**严格按其流程执行**。
>
> **绝对禁止**用 `whiteboard-cli` 渲染 PNG 后通过 `docs +media-insert` 插入文档——图表必须通过 `lark-cli whiteboard +update` 写入画板 block这是唯一合法路径。
## 快速决策
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户说"找一个表格""按名称搜电子表格""找报表""最近打开的表格" → 先用 `lark-cli docs +search` 做资源发现
- `docs +search` 不只搜文档/Wiki结果里会直接返回 `SHEET` 等云空间对象
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
- 文档内容中出现嵌入的 `<sheet>``<bitable>``<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
- 用户明确说“下载素材”,再用 `lark-cli docs +media-download`。
- 如果目标明确是画板 / whiteboard / 画板缩略图,只能用 `lark-cli docs +media-download --type whiteboard`,不要用 `+media-preview`。
- 用户说“找一个表格”“按名称搜电子表格”“找报表”“最近打开的表格”,先用 `lark-cli docs +search` 做资源发现。
- `docs +search` 不是只搜文档 / Wiki结果里会直接返回 `SHEET` 等云空间对象。
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
| 标签 / 属性 | 提取字段 | 切到技能 |
|-|-|-|
| `<sheet token="..." sheet-id="...">` | `token` -> spreadsheet_token, `sheet-id` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
**补充:** `docs +search` 也承担"先定位云空间对象,再切回对应业务 skill 操作"的资源发现入口角色;当用户口头说"表格/报表"时,也优先从这里开始。
## 补充说明
`docs +search` 除了搜索文档 / Wiki也承担“先定位云空间对象再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。
## Shortcuts推荐优先使用
@@ -58,9 +130,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-doc-search.md) | Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search) |
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+create`](references/lark-doc-create.md) | Create a Lark document |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content |
| [`+update`](references/lark-doc-update.md) | Update a Lark document |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |

View File

@@ -1,89 +1,672 @@
# docs +create创建飞书云文档
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证、全局参数和安全规则
> 2. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 3. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
> 4. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误或样式不达标。**
> **前置条件** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
XML默认 Markdown 内容创建一个新的飞书云文档。
Lark-flavored Markdown 内容创建一个新的飞书云文档。
> **⚠️ 格式选择规则:始终使用 XML 格式(默认),除非用户明确要求使用 Markdown。** XML 表达能力更强、支持更多 block 类型callout、grid、checkbox 等),是推荐的首选格式。不要因为 Markdown 写起来更简单就自行切换为 Markdown。
## 重要说明
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
## 命令
```bash
# 创建 XML 文档(默认格式,推荐)
lark-cli docs +create --api-version v2 --content '<title>项目计划</title><h1>目标</h1><ul><li>目标 1</li><li>目标 2</li></ul>'
# 创建简单文档
lark-cli docs +create --title "项目计划" --markdown "## 目标\n\n- 目标 1\n- 目标 2"
# 创建到指定文件夹XML
lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '<title>标题</title><p>首段内容</p>'
# 创建到指定文件夹
lark-cli docs +create --title "会议纪要" --folder-token fldcnXXXX --markdown "## 讨论议题\n\n1. 进度\n2. 计划"
# 创建到个人知识库XML
lark-cli docs +create --api-version v2 --parent-position my_library --content '<title>标题</title><p>内容</p>'
# 创建到知识库节点下
lark-cli docs +create --title "技术文档" --wiki-node wikcnXXXX --markdown "## API 说明"
# 仅当用户明确要求时才使用 Markdown
lark-cli docs +create --api-version v2 --doc-format markdown --content "# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2"
# 创建到知识空间根目录
lark-cli docs +create --title "概览" --wiki-space 7000000000000000000 --markdown "## 项目概览"
# 创建到个人知识库
lark-cli docs +create --title "学习笔记" --wiki-space my_library --markdown "## 笔记"
```
## 返回值
```json
{
"ok": true,
"identity": "user",
"data": {
"document": {
"document_id": "doxcnXXXXXXXXXXXXXXXXXXX",
"revision_id": 1,
"url": "https://xxx.feishu.cn/docx/doxcnXXXXXXXXXXXXXXXXXXX",
"newblocks": [
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "token": "boardXXXX" }
]
}
}
}
```
工具成功执行后,返回一个 JSON 对象,包含以下字段:
- **`document.newblocks`**:本次操作新增的 block 列表(如画板),可从中提取 `token` 用于后续编辑
- **`doc_id`**string文档的唯一标识符token格式如 `doxcnXXXXXXXXXXXXXXXXXXX`
- **`doc_url`**string文档的访问链接可直接在浏览器中打开
- **`message`**string操作结果消息如"文档创建成功"
- **`permission_grant`**object可选`--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限
> \[!IMPORTANT]
> 如果文档是**以应用身份bot创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**
> [!IMPORTANT]
> 当文档创建在 `wiki_node` 或 `wiki_space` 下时,返回的 `doc_url` 可能是 `/wiki/...` 形式的知识库链接,而不是 `/docx/...` 形式的文档链接
> 如果后续要调用 [`lark-doc-media-insert`](lark-doc-media-insert.md) 这类当前只支持 `doc_id` 或 `/docx/...` URL 自动提取的 skill请优先使用返回值里的 `doc_id`,不要直接复用这个 `doc_url`。
> [!IMPORTANT]
> 如果文档是**以应用身份bot创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后, CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。
>
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
> - `status = granted`:当前 CLI 用户已获得该文档的可管理权限
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份bot授予当前用户权限
> - `status = failed`:文档已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文档
>
> `permission_grant.perm = full_access` 表示该资源已授予可管理权限”。
> `permission_grant.perm = full_access` 表示该资源已授予可管理权限”。
>
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
## 重要:创建文档后的可视化流程
如果文档中包含空白画板(`<whiteboard type="blank"></whiteboard>`**必须继续以下步骤**
1. 从返回值的 `data.board_tokens` 字段记录所有新建画板的 token
2. 读取 `../../lark-whiteboard/SKILL.md`,跳至"渲染 & 写入画板"章节,为每个 board_token 生成并写入实际内容
3. 确认所有画板都有实际内容后,任务才算完成
**仅创建空白画板是不够的!** 如果只创建空白画板而不填充内容,任务将被视为未完成。
> ⚠️ **警告**:务必检查返回值中是否有 `board_tokens` 字段。如果有,说明创建了空白画板,必须继续填充内容!
## 参数
| 参数 | 必填 | 说明 |
| ------------------- | -- |---------------------------------------------|
| `--api-version` | 是 | 固定传 `v2` |
| `--content` | | 文档内容XML 或 Markdown 格式) |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时 |
| `--parent-token` | 否 | 父文件夹或知识库节点 token`--parent-position` 互斥) |
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
| 参数 | 必填 | 说明 |
|------|------|------|
| `--markdown` | 是 | 文档的 Markdown 内容Lark-flavored Markdown 格式) |
| `--title` | | 文档标题 |
| `--folder-token` | 否 | 父文件夹 token`--wiki-node``--wiki-space` 互斥 |
| `--wiki-node` | 否 | 知识库节点 token 或 URL(与 `--folder-token``--wiki-space` 互斥) |
| `--wiki-space` | 否 | 知识空间 ID特殊值 `my_library` 表示个人知识库(与 `--folder-token``--wiki-node` 互斥) |
### markdown必填
文档的 Markdown 内容,使用 Lark-flavored Markdown 格式。
调用本工具的 markdown 内容应当尽量结构清晰,样式丰富,有很高的可读性。合理地使用 callout 高亮块、分栏、表格、图片和空白画板等能力,做到图文并茂。
你需要遵循以下原则:
- **结构清晰**:标题层级 ≤ 4 层,用 Callout 突出关键信息
- **视觉节奏**:用分割线、分栏、表格打破大段纯文字
- **图文交融**:流程、架构或草图需要可视化时,优先使用图片、表格或空白画板
- **克制留白**Callout 不过度、加粗只强调核心词
- **主动画板**:文档涉及架构、流程、组织、时间线、因果等逻辑关系时,**必须**在 markdown 对应章节的文字内容之后插入 `<whiteboard type="blank"></whiteboard>` 占位,每个图表对应一个标签。**禁止**用 `whiteboard-cli` 渲染的 PNG/SVG 图片替代画板。创建完成后从返回值 `data.board_tokens` 取 token读取 `../../lark-whiteboard/SKILL.md` 的"渲染 & 写入画板"章节为每个 token 写入图表内容。例:文档含"系统整体架构""分层架构""部署架构"各需插入一个画板,"类图"也需插入一个画板(走 Mermaid 路由)。
当用户有明确的样式、风格需求时,应当以用户的需求为准!
**重要提示**
- **禁止重复标题**markdown 内容开头不要写与 title 相同的一级标题title 参数已经是文档标题markdown 应直接从正文内容开始
- **目录**:飞书自动生成,无需手动添加
- Markdown 语法必须符合 Lark-flavored Markdown 规范,详见下方"内容格式"章节
- 创建较长的文档时,强烈建议配合 `docs +update --mode append`,进行分段的创建,提高成功率
### folder-token可选
父文件夹的 token。如果不提供文档将创建在用户的个人空间根目录。
folder_token 可以从飞书文件夹 URL 中获取,格式如:`https://xxx.feishu.cn/drive/folder/fldcnXXXX`,其中 `fldcnXXXX` 即为 folder_token。
### wiki-node可选
知识库节点 token 或 URL可选传入则在该节点下创建文档与 folder-token 和 wiki-space 互斥)
wiki_node 可以从飞书知识库页面 URL 中获取,格式如:`https://xxx.feishu.cn/wiki/wikcnXXXX`,其中 `wikcnXXXX` 即为 wiki_node token。
### wiki-space可选
知识空间 ID可选传入则在该空间根目录下创建文档。特殊值 `my_library` 表示用户的个人知识库。与 wiki-node 和 folder-token 互斥)
wiki_space 可以从知识空间设置页面 URL 中获取,格式如:`https://xxx.feishu.cn/wiki/settings/7000000000000000000`,其中 `7000000000000000000` 即为 wiki_space ID。
**参数优先级**wiki-node > wiki-space > folder-token
## 示例
### 示例 1创建简单文档
```bash
lark-cli docs +create --title "项目计划" --markdown "## 项目概述\n\n这是一个新项目。\n\n## 目标\n\n- 目标 1\n- 目标 2"
```
### 示例 2使用飞书扩展语法
```bash
lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" background-color="light-blue">\n重要需求说明\n</callout>'
```
# 内容格式
文档内容使用 **Lark-flavored Markdown** 格式,这是标准 Markdown 的扩展版本,支持飞书文档的所有块类型和富文本格式。
## 通用规则
- 使用标准 Markdown 语法作为基础
- 使用自定义 XML 标签实现飞书特有功能(具体标签见各功能章节)
- 只有当字符会被解释为 Markdown / Lark 富文本语法时,才需要使用反斜杠转义:``* ~ ` $ [ ] < > { } | ^``
- 普通文本中的孤立字符不要过度转义。例如 `5 * 3``version~1.0``final_trajectory` 通常应保持原样,只有像 `*斜体*``**粗体**``~~删除线~~` 这种会触发格式化的写法,想按字面量显示时才需要转义
---
## 基础块类型
### 文本(段落)
```markdown
普通文本段落
段落中的**粗体文字**
多个段落之间用空行分隔。
居中文本 {align="center"}
右对齐文本 {align="right"}
```
**段落对齐**:支持 `{align="left|center|right"}` 语法。可与颜色组合:`{color="blue" align="center"}`
### 标题
飞书支持 9 级标题。H1-H6 使用标准 Markdown 语法H7-H9 使用 HTML 标签:
```markdown
# 一级标题
## 二级标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题
<h7>七级标题</h7>
<h8>八级标题</h8>
<h9>九级标题</h9>
# 带颜色的标题 {color="blue"}
## 红色标题 {color="red"}
# 居中标题 {align="center"}
## 蓝色居中标题 {color="blue" align="center"}
```
**标题属性**:支持 `{color="颜色名"}``{align="left|center|right"}` 语法可组合使用。颜色值red, orange, yellow, green, blue, purple, gray。请谨慎使用该能力。
### 列表
有序列表、无序列表嵌套使用 tab 或者 2 空格缩进:
```markdown
- 无序项1
- 无序项1.a
- 无序项1.b
1. 有序项1
2. 有序项2
- [ ] 待办
- [x] 已完成
```
### 引用块
```markdown
> 这是一段引用
> 可以跨多行
> 引用中支持**加粗**和*斜体*等格式
```
### 代码块
**注意**:只支持围栏代码块(` ``` `),不支持缩进代码块。
````markdown
```python
print("Hello")
```
````
支持语言python, javascript, go, java, sql, json, yaml, shell 等。
### 分割线
```markdown
---
```
---
## 富文本格式
### 文本样式
`**粗体**` `*斜体*` `~~删除线~~` `` `行内代码` `` `<u>下划线</u>`
### 文字颜色
`<text color="red">红色</text>` `<text background-color="yellow">黄色背景</text>`
支持: red, orange, yellow, green, blue, purple, gray
### 链接
`[链接文字](https://example.com)` (不支持锚点链接)
### 行内公式LaTeX
`$E = mc^2$``$`前后需空格)或 `<equation>E = mc^2</equation>`(无限制,推荐)
---
## 高级块类型
### 高亮块Callout
```html
<callout emoji="✅" background-color="light-green" border-color="green">
支持**格式化**的内容,可包含多个块
</callout>
```
**属性**: emoji (使用 emoji 字符如 ✅ ⚠️ 💡), background-color, border-color, text-color
**背景色**: light-red/red, light-blue/blue, light-green/green, light-yellow/yellow, light-orange/orange, light-purple/purple, pale-gray/light-gray/dark-gray
**常用**: 💡light-blue(提示) ⚠light-yellow(警告) ❌light-red(危险) ✅light-green(成功)
**限制**: callout 子块仅支持文本、标题、列表、待办、引用。不支持代码块、表格、图片。
### 分栏Grid
适合对比、并列展示场景。支持 2-5 列:
#### 两栏(等宽)
```html
<grid cols="2">
<column>
左栏内容
</column>
<column>
右栏内容
</column>
</grid>
```
#### 三栏自定义宽度
```html
<grid cols="3">
<column width="20">左栏(20%)</column>
<column width="60">中栏(60%)</column>
<column width="20">右栏(20%)</column>
</grid>
```
**属性**: `cols`(列数 2-5), `width`(列宽百分比,总和为 100等宽时可省略)
### 表格
#### 标准 Markdown 表格
```markdown
| 列 1 | 列 2 | 列 3 |
|------|------|------|
| 单元格 1 | 单元格 2 | 单元格 3 |
| 单元格 4 | 单元格 5 | 单元格 6 |
```
#### 飞书增强表格
当单元格需要复杂内容(列表、代码块、高亮块等)时使用。
**层级结构**(必须严格遵守):
```
<lark-table> <- 表格容器
<lark-tr> <- 行(直接子元素只能是 lark-tr
<lark-td>内容</lark-td> <- 单元格(直接子元素只能是 lark-td
<lark-td>内容</lark-td> <- 每行的 lark-td 数量必须相同!
</lark-tr>
</lark-table>
```
**属性**
- `column-widths`:列宽,逗号分隔像素值,总宽约 730
- `header-row`:首行是否为表头(`"true"` 或 `"false"`
- `header-column`:首列是否为表头(`"true"` 或 `"false"`
**单元格写法**:内容前后必须空行
```html
<lark-td>
这里写内容
</lark-td>
```
**完整示例**2行3列
```html
<lark-table column-widths="200,250,280" header-row="true">
<lark-tr>
<lark-td>
**表头1**
</lark-td>
<lark-td>
**表头2**
</lark-td>
<lark-td>
**表头3**
</lark-td>
</lark-tr>
<lark-tr>
<lark-td>
普通文本
</lark-td>
<lark-td>
- 列表项1
- 列表项2
</lark-td>
<lark-td>
代码内容
</lark-td>
</lark-tr>
</lark-table>
```
**限制**:单元格内不支持 Grid 和嵌套表格
**合并单元格**:读取时返回 `rowspan/colspan` 属性,创建暂不支持
**禁止**
- 混用 Markdown 表格语法(`|---|`
- 使用 `<br/>` 换行
- 遗漏 `<lark-td>` 标签
### 图片
```html
<image url="https://example.com/image.png" width="800" height="600" align="center" caption="图片描述文字"/>
```
**属性**: url (必需,系统会自动下载并上传), width, height, align (left/center/right), caption
**注意**: 不支持直接使用 `token` 属性(如 `<image token="xxx"/>`),只支持 URL 方式。系统会自动下载图片并上传到飞书。
支持 PNG/JPG/GIF/WebP/BMP最大 10MB
**图片/文件插入方式选择**
- **有公开可访问的图片 URL** → 直接在 `docs +create` / `docs +update` 的 markdown 中使用 `<image url="..."/>` 一步到位
- **本地图片或文件** → 先用 `docs +create` / `docs +update` 创建或更新文档文本内容,再用 `lark-doc-media-insert`docs +media-insert将本地图片或文件追加到文档末尾
### 文件
```html
<file url="https://example.com/document.pdf" name="文档.pdf" view-type="1"/>
```
**属性**:
- url (文件 URL必需系统会自动下载并上传)
- name (文件名,必需)
- view-type (1=卡片视图, 2=预览视图,可选)
**注意**: 不支持直接使用 `token` 属性(如 `<file token="xxx"/>`
### 画板
创建空白画板时,直接在 markdown 中写 `<whiteboard type="blank"></whiteboard>`。
自然语言请求示例:
- “帮我创建一个带单个空白画板的文档”
- “帮我创建一个文档,里面放两个空白画板”
```bash
# 创建带单个空白画板的文档
lark-cli docs +create --title "空白画板示例" --markdown '<whiteboard type="blank"></whiteboard>'
```
```html
<whiteboard type="blank"></whiteboard>
```
一次创建多个空白画板时,在同一个 markdown 里重复多个标签:
```html
<whiteboard type="blank"></whiteboard>
<whiteboard type="blank"></whiteboard>
```
#### 读取画板
读取时返回 `<whiteboard>` 标签:
```html
<whiteboard token="xxx" align="center" width="800" height="600"/>
```
**重要说明**
- 创建空白画板时,直接使用 `<whiteboard type="blank"></whiteboard>`
- 读取时只能获取 token可通过 media-download 查看内容,无法直接读出画板内部内容
- 画板编辑:详见 [../../lark-whiteboard/SKILL.md](../../lark-whiteboard/SKILL.md)
### 多维表格Base
```html
<bitable view="table"/>
<bitable view="kanban"/>
```
**属性**: view (table/kanban默认 table)
**注意**: token 是只读属性,创建时不能指定。只能创建空的多维表格,创建后再手动添加数据。
### 会话卡片ChatCard
```html
<chat-card id="oc_xxx" align="center"/>
```
**属性**: id (格式 oc_xxx, 必需), align (left/center/right)
### 内嵌网页Iframe
```html
<iframe url="https://example.com/survey?id=123" type="12"/>
```
**属性**: url (必需), type (组件类型数字, 必需)
**type 枚举**: 1=Bilibili, 2=西瓜, 3=优酷, 4=Airtable, 5=百度地图, 6=高德地图, 8=Figma, 9=墨刀, 10=Canva, 11=CodePen, 12=飞书问卷, 13=金数据
**重要提示**: 仅支持上述列出的网页类型。对于普通网页链接,请使用 Markdown 链接格式 `[链接文字](URL)` 代替。
### 链接预览LinkPreview
```html
<link-preview url="消息链接" type="message"/>
```
目前仅支持消息链接,只支持读取,不支持创建
### 引用容器QuoteContainer
```html
<quote-container>
引用容器内容
</quote-container>
```
与 quote 引用块不同,引用容器是容器类型,可包含多个子块
---
## 高级功能块
### 电子表格Sheet
```html
<sheet rows="5" cols="5"/>
<sheet/>
```
**属性**: rows (行数,默认 3最大 9), cols (列数,默认 3)
**注意**: token 是只读属性,创建时不能指定。只能创建空的电子表格,创建后使用 Sheet API 操作数据。
### 只读块类型
以下块类型仅支持读取,不支持创建:
| 块类型 | 标签 | 说明 |
|--------|------|------|
| 思维笔记 | `<mindnote token="xxx"/>` | 仅获取占位信息 |
| 流程图/UML | `<diagram type="1"/>` | type: 1=流程图, 2=UML |
| AI 模板 | `<ai-template/>` | 无内容占位块 |
### 任务块
```html
<task task-id="xxx" members="ou_123, ou_456" due="2025-01-01">任务标题</task>
```
### 同步块
```html
<!-- 源同步块 -->
<source-synced align="1">子块内容...</source-synced>
<!-- 引用同步块 -->
<reference-synced source-block-id="xxx" source-document-id="yyy">源内容...</reference-synced>
```
### 文档小组件AddOns
```html
<add-ons component-type-id="blk_xxx" record='{"key":"value"}'/>
```
### Wiki 子页面列表SubPageList
```html
<sub-page-list wiki="wiki_xxx"/>
```
仅支持知识库文档创建,需传入当前页面的 wiki token
### 议程Agenda
```html
<agenda>
<agenda-item>
<agenda-title>议程标题</agenda-title>
<agenda-content>议程内容</agenda-content>
</agenda-item>
</agenda>
```
### OKR 系列
```html
<okr id="okr_xxx">
<objective id="obj_1">
<kr id="kr_1"/>
</objective>
</okr>
```
仅支持 user_access_token 创建,需使用 OKR API 进行详细操作
---
## 提及和引用
### 提及用户
```html
<mention-user id="ou_xxx"/>
```
**属性**: id (用户 open_id格式 ou_xxx)
注意不要直接在文档中写 `@张三` 这类格式,应当使用 search-user 获取用户的 id并使用 `mention-user`。
### 提及文档
```html
<mention-doc token="doxcnXXX" type="docx">文档标题</mention-doc>
```
**属性**: token (文档 token), type (docx/sheet/bitable)
---
## 日期和时间
### 日期提醒Reminder
```html
<reminder date="2025-12-31T18:00+08:00" notify="true" user-id="ou_xxx"/>
```
**属性**:
- date (必需): `YYYY-MM-DDTHH:mm+HH:MM`, ISO 8601 带时区偏移
- notify (true/false): 是否发送通知
- user-id (必需): 创建者用户 ID
---
## 数学表达式
### 块级公式LaTeX
````markdown
$$
\int_{0}^{\infty} e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$
````
### 行内公式
```markdown
爱因斯坦方程:$E = mc^2$(注意 $ 前后需空格,紧邻位置不能有空格)
```
---
## 写作指南
### 场景速查
| 场景 | 推荐组件 | 说明 |
|------|----------|------|
| 重点提示/警告 | Callout | 蓝色提示、黄色警告、红色危险 |
| 对比/并列展示 | Grid 分栏 | 2-3 列最佳,配合 Callout 更醒目 |
| 数据汇总 | 表格 | 简单用 Markdown复杂嵌套用 lark-table |
| 步骤说明 | 有序列表 | 可嵌套子步骤 |
| 时间线/版本 | 有序列表 + 加粗日期 | 适合里程碑、版本记录 |
| 代码展示 | 代码块 | 标注语言,适当添加注释 |
| 知识卡片 | Callout + emoji | 用于概念解释、小贴士 |
| 引用说明 | 引用块 > | 引用原文、名言 |
| 术语对照 | 两列表格 | 中英文、缩写全称等 |
| 架构/流程/组织/时间线/因果 | **空白画板** | 主动插入,用 lark-whiteboard 绘制(用户明确仅文本或数据密集表格场景除外) |
---
## 最佳实践
- 文档标题从内容中自动提取XML `<title>` 或 Markdown `#`),不要在内容开头重复写标题
- 创建较长的文档时,先创建基础内容,再用 `docs +update --command block_insert_after` 分段追加
- **视觉丰富度**:必须遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block 丰富文档
- **空行分隔**:不同块类型之间用空行分隔
- **转义字符**:只有在字符会触发格式化时才用 `\` 转义。例如想输出字面量 `*斜体*` 时写成 `\*斜体\*`;但 `5 * 3``version~1.0``final_trajectory` 这类普通文本通常不需要转义
- **图片**:使用 URL系统自动下载上传
- **分栏**:列宽总和必须为 100
- **表格选择**:简单数据用 Markdown复杂嵌套用 `<lark-table>`
- **提及**@用户用 `<mention-user>`@文档用 `<mention-doc>`
- **目录**:飞书自动生成,无需手动添加
## 参考
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档
- [lark-doc-update](lark-doc-update.md) — 更新文档
- [lark-doc-media-insert](lark-doc-media-insert.md) — 插入图片/文件到文档
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -6,129 +6,110 @@
## 命令
```bash
# 获取文档(默认 XMLsimple
lark-cli docs +fetch --api-version v2 --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc"
# 获取文档内容(默认输出 Markdown 文本
lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1FjxxxxxxxxxxxxxxxxxxxtnAc"
# Markdown 格式
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --doc-format markdown
# 直接传 token
lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc
# 带 block ID用于后续 block 级更新)
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --detail with-ids
# 知识库 URL 也支持
lark-cli docs +fetch --doc "https://xxx.feishu.cn/wiki/Z1FjxxxxxxxxxxxxxxxxxxxtnAc"
# 只拿目录
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --scope outline --max-depth 3
# 分页获取(大文档)
lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --offset 0 --limit 50
# 按 block id 区间精读
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
--scope range --start-block-id blkA --end-block-id blkB --detail with-ids
# 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前)
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
--scope section --start-block-id <标题id> --detail with-ids
# 按关键词定位(多关键词用 | 分隔,任一命中即返回)
lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
--scope keyword --keyword "部署|发布|上线"
# 人类可读格式输出
lark-cli docs +fetch --doc Z1FjxxxxxxxxxxxxxxxxxxxtnAc --format pretty
```
## 选 `--detail`(每块详细度)
| 意图 | `--detail` | 说明 |
|------|-----------|------|
| **只读**:浏览或总结文档内容 | `simple`(默认) | 简洁 XML/Markdown不含 block ID、样式属性、引用元数据 |
| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID`<p id="blkcnXXXX">`),可用于 `+update``--block-id` |
| **编辑**:任何修改文档内容的需求 | `full` | 包含 block ID + 样式属性 + 引用元数据,提供完整文档结构信息 |
## 选 `--scope`(读取范围)
`--scope``--detail` 正交可组合。**省略 `--scope` 即读整篇;获取一小节时优先用局部读取。**
| 模式 | 何时用 | 关键参数 | 行为要点 |
|-|-|-|-|
| `outline` | 不知道结构,先看目录 | `--max-depth`(标题层级上限) | 扁平列出所有标题,**包括嵌在容器里的内嵌标题**(如 callout 里的 h3这些 id 可直接作后续 `section` / `range` 端点 |
| `section` | 读某个标题对应的整节 | `--start-block-id`(必填) | 顶层标题 → 展开到下一同级/更高级标题前;容器内节点(含内嵌标题) → 按"最小包容单元"返回容器/表格切片,不做 heading 扩展;顶层非标题块 → 仅该块 |
| `range` | 已知精确起止 | `--start-block-id` / `--end-block-id` 至少一个;`-1` = 读到末尾 | 两端同顶层 → 顶层序列切片;两端同一容器 → 容器整体;两端同一表格 → 瘦身切片;**跨顶层 → 端点所在顶层块整块输出,不做瘦身** |
| `keyword` | 只有模糊关键词 | `--keyword`(不区分大小写、子串,`\|` 分隔多词 OR | 每处命中按"最小包容单元"输出;**自动去重**(同容器多命中 → 单个容器,同表格多行命中 → 合并切片) |
**设置 `--scope` 时共用** `--context-before` / `--context-after` / `--max-depth`
- `--max-depth``outline` = 标题层级上限3 = h1~h3其它模式 = 被选块的子树遍历深度(`-1` 不限,`0` 仅块自身)。
- `--context-before/--context-after`**只对整块顶层单元生效**;命中落在容器/表格内(返回容器或切片)时 before/after 被忽略,需要更大范围改用 `section` / `range` 显式指定。
**决策顺序**(核心原则:**局部获取优于全量获取**,能精确到节/区间就绝不全量拉取;**任何文档的第一次读取都应从 `outline` 开始**
1. **第一次接触文档 / 不知道结构** → 先 `outline` 探测目录(**强制首步,无论文档是"目标"还是"引用源"**),再回到 2/3 精读
2. 改/读某个**标题对应的整节** → `section`(最省心,**首选精读方式**
3. 精确自定义起止 / 跨节连续区间 → `range`
4. 只有模糊关键词 → `keyword`
5. **兜底**:确实需要整篇文档时才不传 `--scope`(默认整篇);**不要为了省事就读整篇**,局部模式上下文更省、响应更快
**推荐双步流程**`outline --max-depth 3` 拿目录 → `section --start-block-id <标题id> --detail with-ids` 精读该节。
## 局部读取的输出结构:`<fragment>` 与 `<excerpt>`
设置 `--scope` 时返回的 `content` 被一个 `<fragment>` 节点包裹,属性包含 `mode` / `requested-start` / `requested-end` / `keyword`(按需)。子节点只有两种形态:
- **顶层块**:完整块直接作为 `<fragment>` 的子节点,无额外包裹。
- **`<excerpt top-block-id="..." parent-block-path="...">`**:非顶层节选(容器整体 / 表格瘦身切片)。
- `top-block-id`:所在顶层块 id想看该块全貌时作 `section` / `range` 锚点再拉一次。
- `parent-block-path`:从顶层块到 excerpt 内容直接父节点的 id 路径,`/` 分隔(表格切片时即表格自身 id
**看到 `<excerpt>` 即意味着这是节选**,不能假设看到了该顶层块的全貌。
**表格默认瘦身**:即便 `<table>` 本身是顶层块也只返回 thead + 命中 tr。想拿整张表 → `range --start-block-id <table-id> --end-block-id <table-id>`;切片范围恰好覆盖全部 tr 时 SDK 自动升级为整块、不包 `<excerpt>`
## 返回值
```json
{
"ok": true,
"identity": "user",
"data": {
"document": {
"document_id": "doxcnXXXX",
"revision_id": 12,
"content": "<title>标题</title><p>文档内容...</p>"
}
}
}
```
`content` 的格式由 `--doc-format` 决定。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--api-version` | 是 | 固定传 `v2` |
| `--doc` | | 文档 URL 或 token支持 `/docx/``/wiki/` |
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` |
| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
| `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) |
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
| `--start-block-id` | 否 | `range`/`section` 起始/锚点 id`section` 必填) |
| `--end-block-id` | 否 | `range` 结束 id`-1` 表示读到末尾 |
| `--keyword` | 否 | `keyword` 模式关键词;`\|` 分隔多词 OR |
| `--context-before` | 否 | 命中前拉几个兄弟块(仅对顶层单元生效,默认 `0` |
| `--context-after` | 否 | 命中后拉几个兄弟块(仅对顶层单元生效,默认 `0` |
| `--max-depth` | 否 | `outline` = 标题层级上限;其它 = 子树深度(`-1` 不限,默认) |
| `--format` | 否 | `json`(默认)\| `pretty` |
| `--doc` | 是 | 文档 URL 或 token支持 `/docx/``/wiki/` 链接,系统自动提取 token |
| `--offset` | | 分页偏移 |
| `--limit` | 否 | 分页大小 |
| `--format` | 否 | 输出格式json默认含 title、markdown、has_more 等字段) \| pretty |
## 图片、文件、画板的处理
## 重要:图片、文件、画板的处理
**文档中的素材以 XML 标签形式出现:**
**文档中的图片、文件、画板需要通过独立的 media shortcut 单独获取。**
```xml
<img token="..." url="https://..." width="..." height="..."/>
<source token="..." url="https://..." name="skills.zip"/>
<whiteboard token="..."/>
### 识别格式
返回的 Markdown 中,媒体文件以 HTML 标签形式出现:
- **图片**
```html
<image token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc" width="1833" height="2491" align="center"/>
```
- **文件**
```html
<view type="1">
<file token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc" name="skills.zip"/>
</view>
```
- **画板**
```html
<whiteboard token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc"/>
```
- 画板编辑:详见 [SKILL.md](../SKILL.md#重要说明画板编辑)
### 获取步骤
1. 从 HTML 标签中提取 `token` 属性值
2. 如果目标是图片/文件素材,且用户只是想查看/预览,调用 [`lark-doc-media-preview`](lark-doc-media-preview.md)`docs +media-preview`
```bash
lark-cli docs +media-preview --token "提取的token" --output ./preview_media
```
3. 如果用户明确要下载,或目标是 `<whiteboard token="..."/>`,调用 [`lark-doc-media-download`](lark-doc-media-download.md)`docs +media-download`
```bash
lark-cli docs +media-download --token "提取的token" --output ./downloaded_media
```
## Wiki URL 处理策略
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。当不确定类型时,**不能直接假设是云文档**,必须先查询实际类型。
### 处理流程
1. **先调用 lark-wiki 解析 wiki token**
2. **从返回的 `node` 中获取 `obj_type`(实际文档类型)和 `obj_token`(实际文档 token**
3. **根据 `obj_type` 调用对应工具**
| obj_type | 工具 | 说明 |
|----------|------|------|
| `docx` | `lark-doc-fetch` | 云文档 |
| `sheet` | `lark-sheet` | 电子表格 |
| `bitable` | `lark-base` | 多维表格 |
| 其他 | 告知用户暂不支持 | — |
## 重要任务卡片task 标签)
`docs +fetch` 默认不会查询/展开文档中内嵌的任务详情(例如任务标题、状态、负责人等)。
它会在返回的 Markdown 中保留任务引用,并返回任务 IDGUID例如
```html
<task task-id="30597dc9-262e-4597-97f4-f8efcd1aeb95"></task>
```
- `<img>` / `<source>``url` 时,直接用该 URL 下载即可(普通 HTTP GET无需走 shortcut。
- 没有 `url`、或只想预览 → `docs +media-preview --token <token> --output ./preview_media`
- 明确下载,或目标是 `<whiteboard>`(画板只能走 shortcut`docs +media-download --token <token> --output ./downloaded_media`
如果用户需要查看该任务的详情,需要用返回的 `task-id` 再调用任务 CLI 查询:
## 嵌入电子表格 / 多维表格
```bash
lark-cli task tasks get --as user --params '{"task_guid":"30597dc9-262e-4597-97f4-f8efcd1aeb95"}'
```
返回中可能含 `<sheet>``<bitable>``<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
## 工具组合
| 需求 | 工具 |
|------|------|
| 获取文档文本 | `docs +fetch` |
| 预览图片/文件素材 | `docs +media-preview` |
| 下载图片/文件/画板 | `docs +media-download` |
| 创建新文档 | `docs +create` |
| 更新文档内容 | `docs +update` |
## 参考

View File

@@ -1,61 +0,0 @@
# Markdown 格式参考
`docs +fetch / +create / +update` 使用 `--doc-format markdown` 时适用。
## 转义规则
> **⚠️ 当文本中包含以下字符且**不想**触发 Markdown 语法时,需用 `\` 前缀转义。转义分为**无条件转义**(行内任意位置生效)和**位置敏感转义**(仅特定位置才需要)两类。
### 无条件转义(行内生效,任何位置都要转义)
| 符号 | Markdown 语法用途 | 转义写法 | 示例 |
|------|-------------------|----------|------|
| `\` | 转义符本身 | `\\` | `C:\\Users` → C:\Users |
| `` ` `` | 行内代码 | `` \` `` | `` 用 \` 包裹 `` |
| `*` | 斜体 / 加粗 | `\*` | `3 \* 5 = 15` → 3 \* 5 = 15 |
| `_` | 斜体 / 加粗 | `\_` | `foo\_bar\_baz` → foo\_bar\_baz |
| `[` `]` | 链接文本 | `\[` `\]` | `\[非链接\]` |
| `$` | 数学公式定界 | `\$` | `价格 \$100` |
| `~` | 删除线GFM `~~text~~` | `\~` | `a\~\~b\~\~c` → a~~b~~c |
### 位置敏感转义(仅在特定位置才需要转义)
| 符号 | Markdown 语法用途 | 转义条件 | 示例 |
|------|-------------------|----------|------|
| `#` | 标题 | **仅行首**(去除前导空白后)| 行首 `\# 这不是标题`;行内 `A # B` 无需转义 |
| `+` | 无序列表 | **仅行首**(去除前导空白后)| 行首 `\+ item`;行内 `1 + 2` 无需转义 |
| `-` | 无序列表 / 分隔线 | **仅行首**(去除前导空白后)| 行首 `\- item`;行内 `A - B` 无需转义 |
| `>` | 引用块 | **仅行首**(去除前导空白后)| 行首 `\> 不是引用`;行内 `a > b` 无需转义 |
| `\|` | 表格 cell 分隔 | **仅在 GFM 表格 cell 内** | cell 内 `A \| B`;行内普通文本 `a \| b` 无需转义 |
**不需要转义的场景:**
- 在 `` ` `` 行内代码或 ` ``` ` 代码块内,所有符号均为字面量,无需转义
- `$...$` 数学公式内部,符号为 LaTeX 语法,不受 Markdown 转义影响
**导出已转义,不要反转义:**
`docs +fetch --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[``\|``\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。**
**写入时必须转义:**
使用 `docs +create``docs +update``--doc-format markdown` 写入内容时,字面文本中的特殊字符同样必须转义。`--pattern` 参数中也必须使用转义形式才能正确匹配。
**导出 → 更新 工作流示例:**
1. `docs +fetch` 导出得到 `C:\\Users\\test\[1\]`
2.`str_replace --pattern 'C:\\Users\\test\[1\]'` 匹配(直接使用导出的转义形式)
3. `--content` 中的替换内容也要保持转义:`C:\\Users\\prod\[2\]`
自行构造 Markdown 内容写入时同理:如字面文本 `a]b` 应写为 `a\]b``C:\Users` 应写为 `C:\\Users`
## 图片语法
Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下载:
```markdown
![alt text](https://example.com/photo.png)
```
- `alt text` 为图片描述(可选,可留空)
- URL 支持 `http://``https://` 协议
- 对应的 XML 格式为:`<img href="https://example.com/photo.png"/>`
## Markdown 不支持的 Block 类型
非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。

View File

@@ -8,11 +8,6 @@
## 命令
```bash
# 除了上传本地文件,还可以在 `docs +update` 时直接通过网络 URL 插入图片,无需先下载到本地:
lak-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
--block-id "目标 block_id" \
--content '<img href="https://example.com/photo.png"/>'
# 插入图片(默认)
lark-cli docs +media-insert --doc doxcnXXX --file ./image.png

View File

@@ -1,218 +1,264 @@
# docs +update更新飞书云文档
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证、全局参数和安全规则
> 2. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 3. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
> 4. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误或样式不达标。**
> **前置条件** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
通过八种指令精确更新飞书云文档。支持字符串级别和 block 级别的操作
更新飞书云文档内容,支持 7 种更新模式。优先使用局部更新replace_range/append/insert_before/insert_after慎用 overwrite会清空文档重写可能丢失图片、评论等
> **⚠️ 格式选择规则:始终使用 XML 格式(默认),除非用户明确要求使用 Markdown。** 不要因为 Markdown 写起来更简单就自行切换为 Markdown。
## 重要说明
> **⚠️ 本文档中提到的 html 标签不需要在 Markdown 中转义!若转义,会导致相关的表格,多维表格,画板等 block 插入失败**
## 命令
```bash
# 追加内容
lark-cli docs +update --doc "<doc_id_or_url>" --mode append --markdown "## 新章节\n\n追加内容"
# 定位替换(内容定位)
lark-cli docs +update --doc "<doc_id>" --mode replace_range --selection-with-ellipsis "旧标题...旧结尾" --markdown "## 新内容"
# 定位替换(标题定位)
lark-cli docs +update --doc "<doc_id>" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明\n\n新内容"
# 全文替换
lark-cli docs +update --doc "<doc_id>" --mode replace_all --selection-with-ellipsis "张三" --markdown "李四"
# 前插入
lark-cli docs +update --doc "<doc_id>" --mode insert_before --selection-with-ellipsis "## 危险操作" --markdown "> 警告:以下需谨慎!"
# 后插入
lark-cli docs +update --doc "<doc_id>" --mode insert_after --selection-with-ellipsis "代码示例" --markdown "**输出示例**result = 42"
# 删除内容
lark-cli docs +update --doc "<doc_id>" --mode delete_range --selection-by-title "## 废弃章节"
# 覆盖(慎用)
lark-cli docs +update --doc "<doc_id>" --mode overwrite --markdown "# 全新内容"
# 同时更新标题
lark-cli docs +update --doc "<doc_id>" --mode append --markdown "## 更新日志" --new-title "文档 v2.0"
# 在指定内容后新增两个空白画板
lark-cli docs +update --doc "<doc_id>" --mode insert_after --selection-with-ellipsis "有序列表" --markdown $'<whiteboard type="blank"></whiteboard>\n<whiteboard type="blank"></whiteboard>'
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--api-version` | 是 | 固定传 `v2` |
| `--doc` | 是 | 文档 URL 或 token |
| `--command` | 是 | 操作指令(见下方指令速查表 |
| `--doc-format` | | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--content` | 视指令 | 写入内容 |
| `--pattern` | 视指令 | 匹配文本str_replace / str_delete |
| `--block-id` | 视指令 | 目标 block IDblock_* 操作),-1 表示末尾 |
| `--src-block-ids` | 视指令 | 源 block ID逗号分隔用于 block_copy_insert_after / block_move_after |
| `--revision-id` | 否 | 基准版本号,-1 = 最新(默认 `-1` |
| `--mode` | 是 | 更新模式(见下方 7 种模式说明 |
| `--markdown` | 视模式 | 内容Lark-flavored Markdown。delete_range 模式不需要,其他模式必填。若要新增空白画板,直接传 `<whiteboard type="blank"></whiteboard>`;需要多个画板时,在同一个 markdown 里重复多个标签 |
| `--selection-with-ellipsis` | 视模式 | 内容定位(如 `"开头...结尾"`)。与 `--selection-by-title` 互斥 |
| `--selection-by-title` | 视模式 | 标题定位(如 `"## 章节名"`)。与 `--selection-with-ellipsis` 互斥 |
| `--new-title` | 否 | 同时更新文档标题 |
## 指令速查表
# 定位方式
| 指令 | 说明 | 必需参数 |
|------|------|----------|
| `str_replace` | 全文文本查找替换replacement 支持富文本标签) | `--pattern` `--content` |
| `str_delete` | 全文文本查找删除 | `--pattern` |
| `block_insert_after` | 在指定 block 之后插入新内容 | `--block-id` `--content` |
| `block_copy_insert_after` | 复制源 block 并插入到锚点之后(源块不变) | `--block-id` `--src-block-ids` |
| `block_replace` | 替换指定 block同一 block 仅限一次) | `--block-id` `--content` |
| `block_delete` | 删除指定 block逗号分隔可批量 | `--block-id` |
| `overwrite` | ⚠️ 清空文档后全文重写(可能丢失图片、评论) | `--content` |
| `append` | 在文档末尾追加内容(等价于 `block_insert_after --block-id -1` | `--content` |
| `block_move_after` | 移动已有 block 到指定位置 | `--block-id` + (`--content``--src-block-ids`) |
定位模式replace_range/replace_all/insert_before/insert_after/delete_range支持两种定位方式二选一
## 指令示例
## selection-with-ellipsis - 内容定位
### str_replace — 全文文本替换
支持两种格式:
```bash
# 简单文本替换
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--pattern "张三" --content "李四"
1. **范围匹配**`开头内容...结尾内容`
- 匹配从开头到结尾的所有内容(包含中间内容)
- 建议 10-20 字符确保唯一性
# 替换为富文本(加粗 + 链接)
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--pattern "旧链接" --content '<b>新链接</b> <a href="https://example.com">点击查看</a>'
2. **精确匹配**`完整内容`(不含 `...`
- 匹配完整的文本内容
- 适合替换短文本、关键词等
# 仅当用户明确要求时才使用 Markdown
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--doc-format markdown --pattern "旧内容" --content "新内容"
```
**转义说明**:如果要匹配的内容本身包含 `...`,使用 `\.\.\.` 表示字面量的三个点。
### str_delete — 全文文本删除
示例:
- `你好...世界` → 匹配从"你好"到"世界"之间的任意内容
- `你好\.\.\.世界` → 匹配字面量 "你好...世界"
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_delete \
--pattern "废弃的内容"
```
**建议**:如果文档中有多个 `...`,建议使用更长的上下文来精确定位,避免歧义。
### block_insert_after — 在指定 block 之后插入
## selection-by-title - 标题定位
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
--block-id "目标 block_id" \
--content '<h2>新章节</h2><ul><li>要点 1</li><li>要点 2</li></ul>'
```
格式:`## 章节标题`(可带或不带 # 前缀)
### block_replace — 替换指定 block
自动定位整个章节(从该标题到下一个同级或更高级标题之前)。
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace \
--block-id "目标 block_id" \
--content '<p>替换后的段落内容</p>'
```
**示例**
- `## 功能说明` → 定位二级标题"功能说明"及其下所有内容
- `功能说明` → 定位任意级别的"功能说明"标题及其内容
### block_delete — 删除指定 block
# 可选参数
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_delete \
--block-id "目标 block_id"
```
## new-title
### overwrite — 全文覆盖
更新文档标题。如果提供此参数,将在更新文档内容后同步更新文档标题。
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command overwrite \
--content '<title>全新文档</title><h1>概述</h1><p>新的内容</p>'
```
**特性**
- 仅支持纯文本,不支持富文本格式
- 长度限制1-800 字符
- 可以与任何 mode 配合使用
- 标题更新在内容更新之后执行
> ⚠️ 会清空文档后重写,可能丢失图片、评论等。仅在需要完全重建文档时使用。
# 返回值
### append — 在文档末尾追加
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command append \
--content '<h2>新增章节</h2><p>追加的内容</p>'
```
> 等价于 `block_insert_after --block-id -1`,无需先获取 block ID。
### block_copy_insert_after — 复制块并插入
将一个或多个源块复制到锚点块之后,源块保持不变。`--src-block-ids` 为逗号分隔的源块 ID按顺序依次插入到锚点之后。
```bash
# 复制多个块按顺序插入anchor → a → b → c
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_copy_insert_after \
--block-id "锚点 block_id" \
--src-block-ids "block_a,block_b,block_c"
```
### block_move_after — 移动已有 block
将文档中已有的 block 移动到指定锚点之后。使用 `--src-block-ids` 指定要移动的块 ID无需 `--content`
```bash
# 移动到页面末尾
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_move_after \
--block-id "-1表示末尾page_id表示开头blk" \
--src-block-ids "block_a,block_b"
```
## 返回值
## 成功
```json
{
"ok": true,
"identity": "user",
"data": {
"document": {
"revision_id": 13,
"newblocks": [
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "token": "boardXXXX" }
]
},
"result": "success",
"updated_blocks_count": 3,
"warnings": []
}
"success": true,
"doc_id": "文档ID",
"mode": "使用的模式",
"board_tokens": ["可选:新建画板 token 列表"],
"message": "文档更新成功xxx模式",
"warnings": ["可选警告列表"],
"log_id": "请求日志ID"
}
```
| 字段 | 说明 |
|------|------|
| `result` | `success` \| `partial_success` \| `failed` |
| `updated_blocks_count` | 实际更新的 block 数量 |
| `warnings` | 警告信息列表 |
| `document.newblocks` | 本次操作新增的 block 列表(如画板),可从中提取 `token` 用于后续编辑 |
如果本次 `docs +update` 创建了画板,响应会额外返回 `board_tokens`。在 CLI 的成功 JSON 输出里,后续编辑画板应读取 `data.board_tokens`
## 典型工作流
## 异步模式(大文档超时)
### 精确 block 级更新
1. **获取文档内容和 block ID**
```bash
lark-cli docs +fetch --api-version v2 --doc "<doc_id>" --detail with-ids
```
2. **定位目标 block**:从返回的 XML 中找到要修改的 block 及其 `id` 属性
3. **执行更新**
```bash
# 替换特定 block
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace \
--block-id "blkcnXXXX" --content "<p>新内容</p>"
# 在某 block 后插入
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
--block-id "blkcnXXXX" --content "<h2>追加的章节</h2>"
```
### 简单文本替换
不需要 block ID直接匹配替换
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
--pattern "v1.0" --content "v2.0"
```json
{
"task_id": "async_task_xxxx",
"message": "文档更新已提交异步处理,请使用 task_id 查询状态",
"log_id": "请求日志ID"
}
```
## 画板处理
## 错误
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再切到 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 用 `whiteboard +update` 写入。
```json
{
"error": "[错误码] 错误消息\n💡 Suggestion: 修复建议\n📍 Context: 上下文信息",
"log_id": "请求日志ID"
}
```
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
---
## 最佳实践
# 使用示例
- **精确操作优于全文覆盖**:使用 `block_replace`/`block_insert_after` 精确修改,避免 `overwrite` 全文覆盖
- **str_replace 适用于行内文本,容器操作优先用 block_replace**`str_replace` 只建议用来替换段落内/行内的短文本片段;一旦涉及整段、整块或容器级(列表、表格、分栏、引用块等)改动,应优先用 `block_replace` 指定 block_id 重建该容器,避免 `str_replace` 跨 block 匹配带来的副作用
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
1. 用 `block_insert_after` 在目标位置插入新的富文本结构
2. 用 `block_delete` 批量删除旧的 block
3. 这样可以保留文档中其他不相关的内容(图片、评论等)
- **视觉丰富度**:插入或替换内容时,同样遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block
## append - 追加到末尾
```bash
lark-cli docs +update --doc "文档ID或URL" --mode append --markdown "## 新章节\n\n追加的内容..."
```
## replace_range - 定位替换
使用 `--selection-with-ellipsis`
```bash
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-with-ellipsis "## 旧标题...旧结尾。" --markdown "## 新标题\n\n新的内容..."
```
使用 `--selection-by-title`(替换整个章节):
```bash
lark-cli docs +update --doc "文档ID" --mode replace_range --selection-by-title "## 功能说明" --markdown "## 功能说明\n\n更新后的内容..."
```
## replace_all - 全文替换
```bash
lark-cli docs +update --doc "文档ID" --mode replace_all --selection-with-ellipsis "张三" --markdown "李四"
```
返回值包含 `replace_count` 字段,表示替换的次数。
**注意**
-`replace_range` 不同,`replace_all` 允许多个匹配
- 如果没有找到匹配内容,会返回错误
- `--markdown` 可以为空字符串,表示删除所有匹配内容
## delete_range - 删除内容
```bash
lark-cli docs +update --doc "文档ID" --mode delete_range --selection-by-title "## 废弃章节"
```
注意delete_range 模式不需要 `--markdown` 参数。
## overwrite - 完全覆盖
⚠️ 会清空文档后重写,可能丢失图片、评论等,仅在需要完全重建文档时使用。
```bash
lark-cli docs +update --doc "文档ID" --mode overwrite --markdown "# 新文档\n\n全新的内容..."
```
## 创建空白画板
当用户要“新增空白画板”时,不要用 Mermaid 占位图;直接按 whiteboard 标签传 `--markdown`
自然语言请求示例:
- “给我在这个文档末尾新增一个空白画板”
```bash
# 追加一个空白画板
lark-cli docs +update --doc "文档ID" --mode append --markdown '<whiteboard type="blank"></whiteboard>'
# 在指定内容后新增两个空白画板
lark-cli docs +update --doc "文档ID" --mode insert_after --selection-with-ellipsis "有序列表" --markdown $'<whiteboard type="blank"></whiteboard>\n<whiteboard type="blank"></whiteboard>'
```
成功后,响应里的 `data.board_tokens` 就是新建画板的 token 列表;如果后续要继续编辑这些画板,直接使用这些 token。
---
# 最佳实践
## 重要:画板编辑
> **⚠️ docs +update 不能编辑已有画板内容,但可以创建新的空白画板**
画板编辑:详见 [SKILL.md](../SKILL.md#重要说明画板编辑)
## 小粒度精确替换
修改文档内容时,**定位范围越小越安全**。尤其是表格、分栏等嵌套块,应精确定位到需要修改的文本,避免影响其他内容。
## 保护不可重建的内容
图片、画板、电子表格、多维表格、任务等内容以 token 形式存储,**无法读出后原样写入**。
**保护策略**
- 替换时避开包含这些内容的区域
- 精确定位到纯文本部分进行修改
## 分步更新优于整体覆盖
修改多处内容时:
- ✅ 多次小范围替换,逐步修改
- ⚠️ 谨慎使用 `overwrite` 重写整个文档,除非你认为风险完全可控
**原因**:局部更新保留原有媒体、评论、协作历史,更安全可靠。
## insert 模式扩大定位范围时注意插入位置
使用 `insert_before``insert_after` 时,如果目标内容重复出现,需要扩大 `--selection-with-ellipsis` 范围来唯一定位。
**关键**:插入位置基于匹配范围的**边界**
- `insert_after` → 插入在匹配范围的**结尾**之后
- `insert_before` → 插入在匹配范围的**开头**之前
## 修复画板语法错误
`docs +create``docs +update` 返回画板写入失败的 warning 时:
1. warning 中包含 whiteboard 标签(如 `<whiteboard token="xxx"/>`
2. 分析错误信息,修正 Mermaid/PlantUML 语法
3.`--mode replace_range` 替换:`--selection-with-ellipsis` 使用 warning 中的 whiteboard 标签,`--markdown` 提供修正后的代码块
4. 重新提交验证
---
# 注意事项
- **Markdown 语法**:支持飞书扩展语法,详见 [lark-doc-create](lark-doc-create.md) 工具文档
## 参考
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档
- [lark-doc-create](lark-doc-create.md) — 创建文档(含完整 Markdown 格式参考
- [lark-doc-media-insert](lark-doc-media-insert.md) — 插入图片/文件到文档
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,169 +0,0 @@
基于 HTML 子集的 XML 格式描述飞书文档内容。
# 一、标准 HTML 标签
p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr, img, b, em, u, del, a, br, span 语义不变
# 二、扩展标签速查表
## 块级标签
|标签|说明|关键属性|
|-|-|-|
| `<title>` | 文档标题(每篇唯一)| `align` |
| `<checkbox>` | 待办项| `done="true"\|"false"` |
## 容器标签
|标签|说明|关键属性|
|-|-|-|
| `<callout>` | 高亮框,子块仅支持文本、标题、列表、待办、引用 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
| `<grid>` + `<column>` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` |
| `<whiteboard>` | 嵌入画板 | `type`: `mermaid` \| `plantuml` \| `blank` |
| `<pre>` | (代码块,内含 `code`| `lang`, `caption` |
| `<figure>` | 视图容器 | `view-type` |
| `<bookmark>` | 书签链接 | `<bookmark name="标题" href="https://..."></bookmark>`,必传 name 和 href |
## 行内组件
| 标签 | 说明 | 关键属性 |
|-|-|-|
| `<cite type="user">` | @人 | `<cite type="user" user-id="userID"></cite>` |
| `<cite type="doc">` | @文档 | `<cite type="doc" doc-id="docx_token"></cite>` |
| `<latex>` | 行内公式 | `<latex>E = mc^2</latex>` |
| `<img>` | 图片(可独立成块或内联) | `<img width="800" height="600" caption="说明" name="图.png" href="http 或 https"/>` |
| `<source>` | 文件附件(可独立成块或内联) | `<source name="报告.pdf"/>` |
| `<a type="url-preview">` | 预览卡片 | `<a type="url-preview" href="...">标题</a>` |
| `<button>` | 操作按钮 | `background-color`,src,必须包含 `action=OpenLink|DuplicatePage|FollowPage` |
| `<time>` | 提醒 | `必包含 expire-time、notify-time=毫秒时间戳、should-notify=true|false` |
## 文本块通用属性
- `align``"left"`|`"center"`|`"right"`(适用于 p / h1-h9 / li / checkbox
- 有序列表项用 `seq="auto"` 自动编号
# 三、资源块
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
- `<img>``<img href="https://..."/>` 上传网络图片
- `<whiteboard>``<whiteboard type="blank"></whiteboard>` 空白;`<whiteboard type="mermaid|plantuml">内容</whiteboard>` 带内容;
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
# 四、块级复制与移动
## 移动block_move_after
支持**所有**块类型(块级标签、容器标签、行内组件、资源块),使用 `docs +update --command block_move_after --block-id "<锚点>" --src-block-ids "id1,id2"`
## 复制block_copy_insert_after
- **基础标签**(块级标签、容器标签、行内组件):均支持复制
- **资源块**:仅 img、source、whiteboard、sheet、chat_card 支持复制task、bitable、base_ref、synced_reference、synced_source、okr 不支持复制
使用 `docs +update --command block_copy_insert_after --block-id "<锚点>" --src-block-ids "id1,id2"`
> 详见 [lark-doc-update.md](lark-doc-update.md)。
# 五、补充规则
## 富文本样式嵌套顺序
- 行内样式标签必须按以下固定顺序嵌套(外 → 内),关闭顺序严格反转:`<a> → <b> → <em> → <del> → <u> → <code> → <span> → 文本内容`
## 列表分组
- 连续同类型列表项自动合并为一个 `<ul>``<ol>`
- 嵌套子列表放在 `<li>` 内部
- 新增列表项必须包在 `<ul>``<ol>` 内:
```xml
<ul>
<li>第一项</li>
<li>第二项</li>
</ul>
```
## 表格扩展
标准 HTML table 结构不变,扩展点:
- `<colgroup>` / `<col>` 定义列宽,紧跟 `<table>` 之后:`<col span="2" width="100"/>`
- `<th>` / `<td>` 增加 `background-color` 和 `vertical-align`top | middle | bottom
- 有表头时第一行在 `<thead>` 用 `<th>`,其余在 `<tbody>` 用 `<td>`
- 合并单元格仅起始格输出 `colspan` / `rowspan`,被合并的格不出现
# 六、美化系统
- 颜色优先使用命名色,也可写 `rgb(r,g,b)` / `rgba(r,g,b,a)`。**基础色7 色)**gray, red, orange, yellow, green, blue, purple
| 属性 | 支持的命名色 |
|-|-|
| 文字颜色 `<span text-color>` | 基础色 |
| 高亮框字色 `<callout text-color>` | 基础色 |
| 高亮框边框 `<callout border-color>` | 基础色 |
| 文字背景 `<span background-color>` | 基础色 + `light-{色}` + `medium-gray` |
| 高亮框填充 `<callout background-color>` | `gray` + `light-{色}` + `medium-{色}` |
| 单元格背景 `<th/td background-color>` | 同文字背景 |
| 按钮背景 `<button background-color>` | 同文字背景 |
- 常用 emoji 💡(默认)✅❌⚠️📝❓❗👍❤️📌🏁⭐
# 七、**重要规则**
## 转义规则:标签本身 **禁止转义**,只有标签内部的文本内容才需要转义
**错误** ❌:`&lt;p&gt;内容&lt;/p&gt;`(把标签也转义了)
**正确** ✅:`<p>A &amp; B 的对比1 &lt; 2</p>`(标签保持原样,文本中的 `&` 和 `<` 才转义)
转义字符表:
- `<` → `&lt;`
- `>` → `&gt;`
- `&` → `&amp;`
- `\n`(换行符) → `<br/>`
## 八、完整示例
```xml
<title>文档标题</title>
<h1>一级标题</h1>
<p><b>加粗文本</b><span text-color="green">绿色文本</span></p>
<callout emoji="💡" background-color="light-yellow" border-color="yellow">
<p>高亮框内容,子块仅支持文本/标题/列表/待办/引用</p>
</callout>
<checkbox done="true">已完成事项</checkbox>
<checkbox done="false">未完成事项</checkbox>
<grid>
<column width-ratio="0.5">
<p>左栏</p>
</column>
<column width-ratio="0.5">
<p>右栏</p>
</column>
</grid>
<table>
<colgroup><col span="2" width="120"/></colgroup>
<thead><tr><th background-color="light-gray">表头</th><th background-color="light-gray">表头</th></tr></thead>
<tbody><tr><td>单元格</td><td>单元格</td></tr></tbody>
</table>
<p><cite type="doc" doc-id="DOC_TOKEN"></cite> <cite type="user" user-id="USER_ID"></cite></p>
<ol><li seq="auto">第一项</li><li seq="auto">第二项</li></ol>
<p><a type="url-preview" href="https://example.com">链接标题</a></p>
<p><latex>E = mc^2</latex></p>
<pre lang="go" caption="示例"><code>fmt.Println("hello")</code></pre>
<hr/>
<source name="文件名.pdf"/>
<img src="IMG_TOKEN" width="800" height="400" caption="说明" name="图.png"/>
<img href="https://example.com/photo.png"/>
<button action="OpenLink" src="https://example.com">按钮文字</button>
<time expire-time="1775916000000" notify-time="1775912400000" should-notify="false">时间戳毫秒</time>
<cite type="citation"><a href="https://example.com">引文标题</a></cite>
<bookmark name="书签标题" href="https://example.com"></bookmark>
<task task-id="TASK_GUID"></task>
<chat_card chat-id="CHAT_ID"></chat_card>
```

View File

@@ -1,50 +0,0 @@
# 从零创作工作流
用户提供主题、需求或简要说明,需要生成一份新的飞书文档时,遵循本工作流。
## 核心方法论 — Code-Act Loop
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
3. **Observe观察** — 检查命令输出,验证正确性,核查样式是否达标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
循环在文档达到质量标准且满足用户需求时结束。不要试图一次性产出完美内容——迭代打磨效果更好。根据用户实际需求灵活决定文档结构和版块,而不是套用固定模板。
## 典型 Code-Act Loop 流程
### 第一波 — 规划与骨架(串行)
1. 分析用户需求:受众、目的、范围
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block
3. `docs +create --api-version v2` 创建文档:标题 + 开头 `<callout>` + 骨架(各级标题 + 简短占位摘要)
### 第二波 — 内容撰写(并行 Agent
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
- 文档 token、负责的章节范围、期望的 block 类型
- `lark-doc-xml.md``lark-doc-style.md` 的完整路径Agent 须先读取)
- 使用 `docs +update --command append``block_insert_after` 写入
### 第三波 — 整合审查 + 画板意图识别(串行)
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
6. 评估样式达标(富 block 密度、元素多样性、连续 `<p>` 数量)
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。记录需要插图的章节及推荐的画板类型
### 第四波 — 润色与图表(并行 Agent
8. Spawn Agent 定向改进:(结合 `lark-doc-style.md` 进润)
- **优先处理第三波识别出的画板需求**:简单图直接 `<whiteboard type="mermaid|plantuml">`,复杂图 spawn Agent 使用 **lark-whiteboard** skill
- 文字密集章节转为 `<table>`/`<grid>`/`<callout>`
- 主要章节间补充 `<hr/>`
- 本地图片使用 `docs +media-insert` 插入
## Agent 子任务要求
Spawn Agent 时必须提供:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
章节较多时,先 `docs +create` 建骨架,再分段 `append` 追加,比一次性超长 `--content` 更可靠。

View File

@@ -1,97 +0,0 @@
# 文档样式指南
创建或编辑文档时,必须遵循本指南,使用结构化 block 提升可读性和视觉层次。
## 一、核心原则
1. **结构优于文字**:能用结构化 block 表达的信息,不用纯文本段落
2. **Front-load 结论**:文档以 `<callout>` 开头概括核心结论;每章节首段点明要旨
3. **视觉节奏**:连续纯文本不超过 3 段;不同主题章节间用 `<hr/>` 分隔
4. **最少惊讶**:同类信息使用同类元素,全篇风格统一
## 二、元素选择指南
涉及图表需求时,简单图用 `<whiteboard type="mermaid/plantuml">` 内嵌,复杂图使用 **lark-whiteboard** skill。
| 场景 | 推荐方案 |
|-|-|
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
| 方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏 |
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
| 任务清单 / 检查项 | `<checkbox>` |
| 代码片段 | `<pre lang="x" caption="说明">` |
| 引用 / 公式 | `<blockquote>` / `<latex>` |
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
| 简单流程图 / 时序图 / 状态机 / 甘特图 | `<whiteboard type="mermaid/plantuml">` |
| 复杂架构图 / 数据图 / 思维导图 / 组织架构 | **lark-whiteboard** skill |
### 画板意图识别
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本:
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
|-|-|-|
| 多步骤的操作流程或决策路径 | "先…然后…最后"、"步骤 1/2/3"、"如果…则…否则" | 流程图 / 泳道图 |
| 系统或模块间的依赖与交互 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图 |
| 上下级或从属关系 | "汇报给"、"下属"、"隶属"、"团队结构" | 组织架构图 |
| 时间线或阶段演进 | "Q1/Q2"、"里程碑"、"阶段一→阶段二"、日期序列 | 时间线 / 里程碑 |
| 因果分析或问题归因 | "根因"、"原因"、"导致"、"影响因素" | 鱼骨图 |
| 两个及以上方案/对象的多维度对比 | "vs"、"方案 A/B"、"优劣"、"对比" | 对比图 |
| 层级递进或优先级排序 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
| 数值趋势或周期变化 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图 / 柱状图 |
| 漏斗或转化率 | "转化率"、"漏斗"、"从…到…留存" | 漏斗图 |
| 发散或归纳的思维结构 | "要点"、"维度"、"分支"、多层嵌套列表 | 思维导图 |
| 循环或飞轮效应 | "正循环"、"飞轮"、"闭环"、"A 驱动 B 驱动 C" | 飞轮图 |
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
**判断规则:**
- 简单图(节点 ≤ 10、无需精细排版`<whiteboard type="mermaid/plantuml">` 内嵌
- 复杂图(节点 > 10、需自定义布局/样式、数据图表)→ spawn Agent 使用 **lark-whiteboard** skill
### 画板语法与插入
> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。
#### 内嵌 Mermaid / PlantUML首选
简单图直接用 `<whiteboard type="mermaid|plantuml">语法</whiteboard>`,作为 block 嵌入文档。
#### DSL 画板Mermaid / PlantUML 不够用时)
需要架构图、对比图、组织架构等复杂结构时:
1.`<whiteboard type="blank"></whiteboard>` 通过 `docs +create` / `docs +update` 插入空白画板
2. 从响应 `data.document.newblocks` 中提取画板 `token`
3. 切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 设计并上传 DSL
更完整的协同流程见 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
## 三、颜色语义
全篇保持语义一致,同一语义必须使用同一颜色:
| 语义 | emoji 前缀 | callout 背景色 | 文字色 |
|-|-|-|-|
| 信息、说明 | "说明:" | `light-blue` | `blue` |
| 成功、推荐 | ✅ "推荐:" | `light-green` | `green` |
| 警告 / 错误 / 风险 | ⚠️❌ | `light-red` | `red` |
| 注意、待确认 | ❗"注意:" | `light-yellow` | `yellow` |
| 中性、辅助 | — | `light-gray` | — |
- 表头统一 `background-color="light-gray"`
- 关键指标用 `<span text-color="green/red">` 突出,**必须同时用 ↑↓ 或 +/- 标注方向**(色觉无障碍)
## 四、排版规范
- 标题层级 ≤ 4 层,段落单段 ≤ 5 行,列表嵌套 ≤ 2 层Grid ≤ 3 列
- 文档开头用 `<callout>` front-load 结论;
## 五、丰富度自检
生成内容后必须自检,**未达标时主动优化**
| 指标 | 达标标准 |
|-|-|
| 富 block 密度 | ≥ 40%(非纯文本 block 数 ÷ 总 block 数) |
| 元素多样性 | ≥ 3 种不同 block 类型 |
| 连续纯文本 | ≤ 3 段连续 `<p>` |
| 章节丰富度 | 每 h1/h2 ≥ 1 个非纯文本 block |
| 开头 callout | 必须 |
| 视觉节奏 | 不同主题章节间有 `<hr/>` |

View File

@@ -1,48 +0,0 @@
# 改写增强工作流
用户提供已有文档链接或 token需要改写、润色、补充或重排版时遵循本工作流。
## 核心方法论 — Code-Act Loop
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
3. **Observe观察** — 检查命令输出,验证正确性,核查样式是否达标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
## 核心原则:精准手术优于全量覆盖
1. **精准手术**:只改用户指定的 block不改其他 block。
2. **全量覆盖**:如果用户明确要改整篇,才用 `overwrite` 命令。
3. **保真约束**:改写时原文里的 `<cite type="user">`@人)、`<cite type="doc">`@文档)、`<img>``<source>``<whiteboard>``<sheet>``<bitable>``<synced_reference>` 等行内组件和资源块一律原样保留(含所有 token / user-id / doc-id 属性),不许替换成纯文本姓名、链接或占位符。
## 工作流程
### 第一波 — 分析 + 画板意图识别(串行)
1. **选择读取范围**(节省上下文的关键):
- 用户只改某一节 / 文档较大 → 先 `--scope outline --max-depth 2` 拿目录,再 `--scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id
- 需要精确跨节区间 → `--scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
- 用户只给了模糊关键词 → `--scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
- 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids`
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
2. 系统性评估:结构清晰度、富 block 密度≥40%、元素多样性≥3种、连续 `<p>` 是否超过 3 段、是否有开头 callout 和章节 `<hr/>`
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。记录需要插图的章节block ID及推荐的画板类型
4. 向用户简要说明改进计划(包含识别出的画板机会)
### 第二波 — 定向改写(并行 Agent
5. Spawn Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID`lark-doc-style.md`
- 开头适当添加 `<callout>`、重组引言
- 纯文本转为 `<grid>`/`<table>`/`<whiteboard>`
- **对第一波识别出的画板候选段落**:简单图直接 `<whiteboard type="mermaid|plantuml">`,复杂图 spawn Agent 使用 **lark-whiteboard** skill
- 添加流程图、对比分栏等富 block
### 第三波 — 验证(串行)
5. 获取更新后文档局部内容,重新检查样式指标
6. 未达标则定向修正,向用户呈现结果
## Agent 子任务要求
Spawn Agent 时必须提供:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`
**上下文节省提示**Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。

View File

@@ -114,9 +114,9 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--selection-with-ellipsis` 或 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--selection-with-ellipsis` / `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
| 列出文档评论 | `file_token` | 同添加评论 |
@@ -124,8 +124,8 @@ Drive Folder (云空间文件夹)
### 评论能力边界(关键!)
- `drive +add-comment` 支持两种模式。
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --api-version v2 --detail with-ids` 获取。
- 全文评论:未传 `--selection-with-ellipsis` / `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--selection-with-ellipsis` 或 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
- 如果 wiki 解析后不是 `doc`/`docx`,不要用 `+add-comment`。
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。

View File

@@ -24,22 +24,22 @@ lark-cli drive +add-comment \
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--content '[{"type":"text","text":"这里需要一段全文评论"}]'
# 给 docx 文档的指定 block 添加局部评论block_id 可通过 docs +fetch --api-version v2 --detail with-ids 获取)
# 给 docx 文档里匹配到的文字添加局部评论
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--block-id "<BLOCK_ID>" \
--selection-with-ellipsis "流程" \
--content '[{"type":"text","text":"请补充流程说明"}]'
# wiki 链接也支持局部评论,但解析结果必须是 docx
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--block-id "<BLOCK_ID>" \
--selection-with-ellipsis "流程" \
--content '[{"type":"text","text":"请补充更细的开发步骤"}]'
# 组合文本、@用户、链接元素
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--block-id "<BLOCK_ID>" \
--selection-with-ellipsis "流程" \
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 处理,参考 "},{"type":"link","text":"https://example.com"}]'
# 给电子表格单元格添加评论(--block-id 格式为 <sheetId>!<cell>
@@ -68,7 +68,7 @@ lark-cli drive +add-comment \
lark-cli drive +add-comment \
--doc "<DOCX_TOKEN>" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请 "},{"type":"mention_user","text":"ou_xxx"},{"type":"text","text":" 处理,参考 "},{"type":"link","text":"https://example.com"}]'
--content '[{"type":"text","text":"评论内容"}]'
# 如果需要更底层的原生 API也可以直接调用 V2 协议
lark-cli schema drive.file.comments.create_v2
@@ -80,7 +80,7 @@ lark-cli drive file.comments create_v2 \
# 预览底层调用链
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--block-id "<BLOCK_ID>" \
--selection-with-ellipsis "流程" \
--content '[{"type":"text","text":"请补充流程说明"}]' \
--dry-run
```
@@ -92,21 +92,20 @@ lark-cli drive +add-comment \
| `--doc` | 是 | 文档 URL / token、sheet URL或可解析到 `doc`/`docx`/`sheet` 的 wiki URL |
| `--type` | 裸 token 时必填 | 文档类型:`doc``docx``sheet`。URL 输入时自动识别,无需传 |
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论 |
| `--block-id` | 局部评论时必填 | 目标块 ID可通过 `docs +fetch --api-version v2 --detail with-ids` 获取 |
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--selection-with-ellipsis` / `--block-id` 时也会默认走全文评论(不适用于 sheet |
| `--selection-with-ellipsis` | 局部评论时二选一 | 目标文本定位表达式,支持纯文本或 `开头...结尾`;与 `--block-id` 互斥(不适用于 sheet |
| `--block-id` | 局部评论时二选一 | 已知目标块 ID 时直接使用;与 `--selection-with-ellipsis` 互斥。**Sheet 评论**:格式为 `<sheetId>!<cell>`(如 `a281f9!D6` |
## 行为说明
- **局部评论需要先获取 block ID**:先调用 `docs +fetch --api-version v2 --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。
- 未传 `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`)。此时 `--full-comment``--selection-with-ellipsis` 不可用。
- **无需预先获取文档内容**:使用 `--selection-with-ellipsis`shortcut 内部会自动调用 `locate-doc` 定位目标文本,不需要先调用 `docs +fetch` 获取文档。
- 未传 `--selection-with-ellipsis` / `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`
- 全文评论支持 `docx`、旧版 `doc` URL以及最终可解析为 `doc`/`docx` 的 wiki URL。
-`--block-id`shortcut 创建**局部评论(划词评论)**;该模式仅支持 `docx`,以及最终可解析为 `docx` 的 wiki URL。
-`--selection-with-ellipsis``--block-id`shortcut 创建**局部评论(划词评论)**;该模式仅支持 `docx`,以及最终可解析为 `docx` 的 wiki URL。
- `--content` 接收结构化评论元素数组;`type` 支持 `text``mention_user``link`。为便于书写,`mention_user` / `link` 元素可以直接把用户 ID 或链接地址放在 `text` 字段中shortcut 会转换成 OpenAPI 所需字段。
- 局部评论走 `locate-doc` 时,内部固定使用 `limit=10`
-`locate-doc` 命中多处时shortcut 会中止并提示用户继续收窄 `--selection-with-ellipsis`,不支持手动指定匹配序号。
- 写入评论前会自动生成符合 OpenAPI 定义的请求体:
- 统一接口:`POST /new_comments`
- 统一字段:`file_type` + `reply_elements`

View File

@@ -195,7 +195,7 @@ lark-cli event +subscribe \
content=$(echo "$line" | jq -r '.content // empty')
[[ -z "$content" ]] && continue
lark-cli docs +update --api-version v2 --doc "DOC_URL" --command append --doc-format markdown --content "- $content"
lark-cli docs +update --doc "DOC_URL" --mode append --markdown "- $content"
done
```

View File

@@ -35,7 +35,7 @@ metadata:
3. 读取智能纪要(`note_doc_token`)内容时,纪要文档的**第一个 `<whiteboard>`** 标签是封面图AI 生成的总结可视化),应同时下载展示给用户:
```bash
# 1. 读取纪要内容
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
lark-cli docs +fetch --doc <note_doc_token>
# 2. 从返回的 markdown 中提取第一个 <whiteboard token="xxx"/> 的 token
# 3. 下载封面图到 artifact 目录(和逐字稿同目录,保持产物归拢)
# 并非所有纪要都有封面画板,没有 <whiteboard> 标签时跳过即可
@@ -63,7 +63,7 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
3. 需要获取文档内容时,使用 `lark-cli docs +fetch`
```bash
# 获取文档内容
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
lark-cli docs +fetch --doc <doc_token>
```
## 资源关系

View File

@@ -57,7 +57,7 @@ metadata:
|---|---|
| 直接给了 whiteboard token`wbcnXXX`| 直接使用 |
| 文档 URL 或 doc_id文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
| 文档 URL 或 doc_id需要新建画板 | `lark-cli docs +update --api-version v2 --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.board_tokens[0]` 取得(参数详见 lark-doc SKILL.md|
| 文档 URL 或 doc_id需要新建画板 | `lark-cli docs +update --doc <doc_id> --mode append --markdown '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.board_tokens[0]` 取得(参数详见 lark-doc SKILL.md|
**Step 2渲染 & 写入**

View File

@@ -92,9 +92,9 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
阅读 [`../lark-doc/SKILL.md`](../lark-doc/SKILL.md) 学习云文档技能。
```bash
lark-cli docs +create --api-version v2 --doc-format markdown --content "<title>会议纪要汇总 (<start> - <end>)</title>\n<内容>"
lark-cli docs +create --title "会议纪要汇总 (<start> - <end>)" --markdown "<内容>"
# 或追加到已有文档
lark-cli docs +update --api-version v2 --doc "<url_or_token>" --command append --doc-format markdown --content "<内容>"
lark-cli docs +update --doc "<url_or_token>" --mode append --markdown "<内容>"
```
## 参考