Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ func normalisePath(raw string) string {

// NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook).
func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
return NewCmdApiWithContext(context.Background(), f, runF)
}

func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
opts := &APIOptions{Factory: f}
var asStr string

Expand All @@ -79,7 +83,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command

cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
Expand All @@ -96,9 +100,6 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
Expand Down
18 changes: 18 additions & 0 deletions cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,24 @@ func TestApiValidArgsFunction(t *testing.T) {
}
}

func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})

cmd := NewCmdApi(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}

func TestApiCmd_PageLimitDefault(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
Expand Down
132 changes: 132 additions & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// 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
globals GlobalOptions
}

// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
// Terminal detection is derived from the input's underlying *os.File, if any;
// non-file readers (bytes.Buffer, strings.Reader, …) produce IsTerminal=false.
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
}
}

// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
// HideProfile(isSingleAppMode()).
func HideProfile(hide bool) BuildOption {
return func(c *buildConfig) {
c.globals.HideProfile = hide
}
}

// 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 a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
//
// Callers must supply WithIO; buildInternal intentionally does not default
// the streams so tests and alternative entry points can't silently inherit
// os.Std*.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
// cfg.globals.Profile is left zero here; it's bound to the --profile
// flag in RegisterGlobalFlags and filled by cobra's parse step.
cfg := &buildConfig{}
for _, o := range opts {
o(cfg)
}
Comment on lines +87 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against nil functional options to avoid panic.

At Line 68, calling o(cfg) without a nil check can panic if a nil option is passed in opts.

Proposed fix
 	for _, o := range opts {
-		o(cfg)
+		if o != nil {
+			o(cfg)
+		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for _, o := range opts {
o(cfg)
}
for _, o := range opts {
if o != nil {
o(cfg)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/build.go` around lines 67 - 69, The loop over functional options can
panic if a nil option is present; update the code that iterates over opts (the
for _, o := range opts loop) to guard before invoking o(cfg) by checking that o
!= nil and either skip nil entries or filter them out beforehand, ensuring cfg
is only passed to non-nil option functions (references: opts, o, cfg).


f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
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(), &cfg.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.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}

return f, rootCmd
}
31 changes: 27 additions & 4 deletions cmd/global_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,38 @@

package cmd

import "github.com/spf13/pflag"
import (
"github.com/larksuite/cli/internal/core"
"github.com/spf13/pflag"
)

// GlobalOptions are the root-level flags shared by bootstrap parsing and the
// actual Cobra command tree.
// actual Cobra command tree. Profile is the parsed --profile value; HideProfile
// is a build-time policy — when true, --profile stays parseable but is marked
// hidden from help and shell completion.
type GlobalOptions struct {
Profile string
Profile string
HideProfile bool
}

// RegisterGlobalFlags registers the root-level persistent flags.
// RegisterGlobalFlags registers the root-level persistent flags on fs and
// applies any visibility policy encoded in opts. Pure function: no disk,
// network, or environment reads — the caller decides HideProfile.
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
if opts.HideProfile {
_ = fs.MarkHidden("profile")
}
}

// isSingleAppMode reports whether the on-disk config has at most one app.
// Missing configs are treated as single-app since --profile is meaningless
// until at least two profiles exist. Intended for the Execute entry point —
// buildInternal must not call this directly to stay state-free.
func isSingleAppMode() bool {
raw, err := core.LoadMultiAppConfig()
if err != nil || raw == nil {
return true
}
return len(raw.Apps) <= 1
}
110 changes: 110 additions & 0 deletions cmd/global_flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package cmd

import (
"context"
"os"
"testing"

"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/pflag"
)

func testStreams() BuildOption { return WithIO(os.Stdin, os.Stdout, os.Stderr) }

func TestRegisterGlobalFlags_PolicyVisible(t *testing.T) {
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
opts := &GlobalOptions{}
RegisterGlobalFlags(fs, opts)

flag := fs.Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if flag.Hidden {
t.Fatal("profile flag should be visible when HideProfile is false")
}
}

func TestRegisterGlobalFlags_PolicyHidden(t *testing.T) {
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
opts := &GlobalOptions{HideProfile: true}
RegisterGlobalFlags(fs, opts)

flag := fs.Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if !flag.Hidden {
t.Fatal("profile flag should be hidden when HideProfile is true")
}
if err := fs.Parse([]string{"--profile", "x"}); err != nil {
t.Fatalf("Parse() error = %v; hidden flag should still parse", err)
}
if opts.Profile != "x" {
t.Fatalf("opts.Profile = %q, want %q", opts.Profile, "x")
}
}

func TestIsSingleAppMode_NoConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if !isSingleAppMode() {
t.Fatal("isSingleAppMode() = false, want true when no config exists")
}
}

func TestIsSingleAppMode_SingleApp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
saveAppsForTest(t, []core.AppConfig{
{Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
})
if !isSingleAppMode() {
t.Fatal("isSingleAppMode() = false, want true for single-app config")
}
}

func TestIsSingleAppMode_MultiApp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
saveAppsForTest(t, []core.AppConfig{
{Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
{Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu},
})
if isSingleAppMode() {
t.Fatal("isSingleAppMode() = true, want false for multi-app config")
}
}

func TestBuildInternal_HideProfileOption(t *testing.T) {
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))

flag := root.PersistentFlags().Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if !flag.Hidden {
t.Fatal("profile flag should be hidden when HideProfile(true) is applied")
}
}

func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())

flag := root.PersistentFlags().Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered by default")
}
if flag.Hidden {
t.Fatal("profile flag should be visible by default")
}
}

func saveAppsForTest(t *testing.T, apps []core.AppConfig) {
t.Helper()
multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
18 changes: 18 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// 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
}
Loading
Loading