diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 996c71c6..f633a614 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) { } } +func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *CheckOptions + cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected opts to be set") + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } +} + func TestAuthLogoutCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) @@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) { } } +func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *LogoutOptions + cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected opts to be set") + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } +} + func TestAuthListCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) @@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) { } } +func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ListOptions + cmd := NewCmdAuthList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } +} + func TestAuthStatusCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) { } } +func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *StatusOptions + cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } +} + func TestAuthStatusCmd_VerifyFlag(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) { } } +func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *ScopesOptions + cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--format", "pretty", "--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected opts to be set") + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } + if gotOpts.Format != "json" { + t.Errorf("expected format json, got %s", gotOpts.Format) + } +} + func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu, diff --git a/cmd/auth/check.go b/cmd/auth/check.go index 50c47d10..5af63493 100644 --- a/cmd/auth/check.go +++ b/cmd/auth/check.go @@ -19,6 +19,7 @@ import ( type CheckOptions struct { Factory *cmdutil.Factory Scope string + JSON bool } // NewCmdAuthCheck creates the auth check subcommand. @@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra. } cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmd.MarkFlagRequired("scope") cmdutil.SetRisk(cmd, "read") diff --git a/cmd/auth/list.go b/cmd/auth/list.go index ff682f82..d92a028c 100644 --- a/cmd/auth/list.go +++ b/cmd/auth/list.go @@ -18,6 +18,7 @@ import ( // ListOptions holds all inputs for auth list. type ListOptions struct { Factory *cmdutil.Factory + JSON bool } // NewCmdAuthList creates the auth list subcommand. @@ -34,6 +35,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co return authListRun(opts) }, } + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmdutil.SetRisk(cmd, "read") return cmd @@ -44,6 +46,14 @@ func authListRun(opts *ListOptions) error { multi, _ := core.LoadMultiAppConfig() if multi == nil || len(multi.Apps) == 0 { + if opts.JSON { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": true, + "users": []map[string]interface{}{}, + "reason": "not_configured", + }) + return nil + } // auth list is a read-only probe; the "configured but no users" // branch below already returns exit 0 with a stderr hint, so we // keep the same contract here. We still want the hint to be @@ -61,6 +71,14 @@ func authListRun(opts *ListOptions) error { app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil || len(app.Users) == 0 { + if opts.JSON { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": true, + "users": []map[string]interface{}{}, + "reason": "not_logged_in", + }) + return nil + } fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.") return nil } diff --git a/cmd/auth/list_test.go b/cmd/auth/list_test.go index e26266d6..070e4fae 100644 --- a/cmd/auth/list_test.go +++ b/cmd/auth/list_test.go @@ -4,6 +4,7 @@ package auth import ( + "encoding/json" "strings" "testing" @@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) { } } +func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + users, ok := payload["users"].([]any) + if !ok || len(users) != 0 { + t.Errorf("stdout.users = %v, want empty array", payload["users"]) + } + if payload["reason"] != "not_configured" { + t.Errorf("stdout.reason = %v, want not_configured", payload["reason"]) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String()) + } +} + // TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the // reason this hint exists workspace-aware in the first place: an AI agent // in OpenClaw / Hermes that probes auth list before binding gets routed to @@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) t.Errorf("agent hint must not mention config init: %s", out) } } + +func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeLogoutConfig(t, nil) + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + users, ok := payload["users"].([]any) + if !ok || len(users) != 0 { + t.Errorf("stdout.users = %v, want empty array", payload["users"]) + } + if payload["reason"] != "not_logged_in" { + t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"]) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String()) + } +} + +func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeLogoutConfig(t, nil) + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authListRun(&ListOptions{Factory: f}); err != nil { + t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err) + } + + if stdout.Len() != 0 { + t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String()) + } + if !strings.Contains(stderr.String(), "No logged-in users") { + t.Errorf("stderr = %q, want no-users hint", stderr.String()) + } +} diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 1e864fd7..6fdbda5d 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -18,6 +18,7 @@ import ( // LogoutOptions holds all inputs for auth logout. type LogoutOptions struct { Factory *cmdutil.Factory + JSON bool } // NewCmdAuthLogout creates the auth logout subcommand. @@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr return authLogoutRun(opts) }, } + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmdutil.SetRisk(cmd, "write") return cmd @@ -44,12 +46,28 @@ func authLogoutRun(opts *LogoutOptions) error { multi, _ := core.LoadMultiAppConfig() if multi == nil || len(multi.Apps) == 0 { + if opts.JSON { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": true, + "loggedOut": false, + "reason": "not_configured", + }) + return nil + } fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.") return nil } app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil || len(app.Users) == 0 { + if opts.JSON { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": true, + "loggedOut": false, + "reason": "not_logged_in", + }) + return nil + } fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") return nil } @@ -63,6 +81,13 @@ func authLogoutRun(opts *LogoutOptions) error { if err := core.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } + if opts.JSON { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": true, + "loggedOut": true, + }) + return nil + } output.PrintSuccess(f.IOStreams.ErrOut, "Logged out") return nil } diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go new file mode 100644 index 00000000..e341fa46 --- /dev/null +++ b/cmd/auth/logout_test.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "strings" + "testing" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/zalando/go-keyring" +) + +func writeLogoutConfig(t *testing.T, users []core.AppUser) { + t.Helper() + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + CurrentApp: "test-app", + Apps: []core.AppConfig{ + { + AppId: "test-app", + AppSecret: core.PlainSecret("test-secret"), + Brand: core.BrandFeishu, + Users: users, + }, + }, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } +} + +func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + if payload["loggedOut"] != false { + t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"]) + } + if payload["reason"] != "not_configured" { + t.Errorf("stdout.reason = %v, want not_configured", payload["reason"]) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String()) + } +} + +func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeLogoutConfig(t, nil) + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + if payload["loggedOut"] != false { + t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"]) + } + if payload["reason"] != "not_logged_in" { + t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"]) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String()) + } +} + +func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) { + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}) + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "test-app", + UserOpenId: "ou_user", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + if payload["loggedOut"] != true { + t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"]) + } + if _, hasReason := payload["reason"]; hasReason { + t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"]) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String()) + } +} + +func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) { + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}) + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "test-app", + UserOpenId: "ou_user", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + if stdout.Len() != 0 { + t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String()) + } + if !strings.Contains(stderr.String(), "Logged out") { + t.Errorf("stderr = %q, want success text", stderr.String()) + } +} diff --git a/cmd/auth/scopes.go b/cmd/auth/scopes.go index 4de47582..91f290f9 100644 --- a/cmd/auth/scopes.go +++ b/cmd/auth/scopes.go @@ -19,6 +19,7 @@ type ScopesOptions struct { Factory *cmdutil.Factory Ctx context.Context Format string + JSON bool } // NewCmdAuthScopes creates the auth scopes subcommand. @@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr Short: "Query scopes enabled for the app", RunE: func(cmd *cobra.Command, args []string) error { opts.Ctx = cmd.Context() + if opts.JSON { + opts.Format = "json" + } if runF != nil { return runF(opts) } @@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr } cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmdutil.SetRisk(cmd, "read") return cmd diff --git a/cmd/auth/status.go b/cmd/auth/status.go index f0cf85e4..15de7dda 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -17,6 +17,7 @@ import ( type StatusOptions struct { Factory *cmdutil.Factory Verify bool + JSON bool } // NewCmdAuthStatus creates the auth status subcommand. @@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr } cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmdutil.SetRisk(cmd, "read") return cmd