diff --git a/cmd/build.go b/cmd/build.go index 029c8e4d..7ecb4c3b 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -19,6 +19,7 @@ import ( "github.com/larksuite/cli/cmd/service" "github.com/larksuite/cli/cmd/skill" cmdupdate "github.com/larksuite/cli/cmd/update" + "github.com/larksuite/cli/cmd/whoami" _ "github.com/larksuite/cli/events" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/build" @@ -194,6 +195,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B rootCmd.AddCommand(auth.NewCmdAuth(f)) rootCmd.AddCommand(profile.NewCmdProfile(f)) rootCmd.AddCommand(doctor.NewCmdDoctor(f)) + rootCmd.AddCommand(whoami.NewCmdWhoami(f)) rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil)) rootCmd.AddCommand(schema.NewCmdSchema(f, nil)) rootCmd.AddCommand(completion.NewCmdCompletion(f)) diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go new file mode 100644 index 00000000..3356cb18 --- /dev/null +++ b/cmd/whoami/whoami.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package whoami + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identitydiag" + "github.com/larksuite/cli/internal/output" +) + +// whoamiResult is the structured output of `lark-cli whoami`. +type whoamiResult struct { + Profile string `json:"profile"` + AppID string `json:"appId"` + Brand core.LarkBrand `json:"brand"` + DefaultAs string `json:"defaultAs"` + Identity string `json:"identity"` + IdentitySource string `json:"identitySource"` + Available bool `json:"available"` + TokenStatus string `json:"tokenStatus"` + OpenID string `json:"openId,omitempty"` + UserName string `json:"userName,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// Options holds inputs for the whoami command. +type Options struct { + Factory *cmdutil.Factory + As string + JSON bool +} + +// NewCmdWhoami creates the top-level whoami command. It reports the identity +// that the next API call would actually use (resolved via Factory.ResolveAs), +// together with the active profile, app, and token status. It is local-only: +// no network calls are made. +func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command { + opts := &Options{Factory: f} + cmd := &cobra.Command{ + Use: "whoami", + Short: "Show the current effective identity, app, profile, and token status", + RunE: func(cmd *cobra.Command, args []string) error { + return whoamiRun(cmd, opts) + }, + } + cmdutil.DisableAuthCheck(cmd) + cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As) + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") + cmdutil.SetRisk(cmd, "read") + return cmd +} + +func whoamiRun(cmd *cobra.Command, opts *Options) error { + f := opts.Factory + cfg, err := f.Config() + if err != nil { + return err + } + ctx := cmd.Context() + flagAs := core.Identity(opts.As) + as := f.ResolveAs(ctx, cmd, flagAs) + // Reject an explicit --as that does not resolve to a usable identity, so a + // typo like `--as admin` fails clearly instead of echoing back a bogus + // identity. Keeps the §5.1 invariant (identity is always user or bot) and + // matches how api/service/shortcut commands validate the resolved identity. + if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil { + return err + } + source := resolveSource( + cmd.Flags().Changed("as"), + flagAs, + f.IdentityAutoDetected, + f.ResolveStrictMode(ctx).ForcedIdentity(), + ) + diag := identitydiag.Diagnose(ctx, f, cfg, false) + res := buildResult(cfg, as, source, diag) + if opts.JSON { + output.PrintJson(f.IOStreams.Out, res) + return nil + } + formatPretty(f.IOStreams.Out, res) + return nil +} + +// resolveSource derives how the effective identity became effective. +// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an +// auto-detected result means auto-detect; otherwise a strict-mode forced +// identity means strict-mode; otherwise it came from configured default-as. +func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string { + if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) { + return "flag" + } + if autoDetected { + return "auto-detect" + } + if strictForced != "" { + return "strict-mode" + } + return "default-as" +} + +// buildResult maps the resolved identity and local diagnostics into the output. +// ResolveAs only ever returns user or bot, so the default branch handles user. +func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult { + defaultAs := cfg.DefaultAs + if defaultAs == "" { + defaultAs = core.AsAuto + } + res := &whoamiResult{ + Profile: cfg.ProfileName, + AppID: cfg.AppID, + Brand: cfg.Brand, + DefaultAs: string(defaultAs), + Identity: string(as), + IdentitySource: source, + } + switch as { + case core.AsBot: + res.Available = diag.Bot.Available + res.TokenStatus = diag.Bot.Status + if !diag.Bot.Available { + res.Hint = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)." + } + default: // user + res.Available = diag.User.Available + res.OpenID = diag.User.OpenID + res.UserName = diag.User.UserName + res.TokenStatus = diag.User.TokenStatus + if res.TokenStatus == "" { + res.TokenStatus = "missing" + } + if !diag.User.Available { + res.Hint = "No usable user token. Run `lark-cli auth login`." + } + } + return res +} + +// formatPretty writes the human-readable one-glance summary. +func formatPretty(w io.Writer, r *whoamiResult) { + fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand) + fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource) + if r.Identity == string(core.AsUser) && r.UserName != "" { + if r.OpenID != "" { + fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID) + } else { + fmt.Fprintf(w, "User: %s\n", r.UserName) + } + } + token := r.TokenStatus + if !r.Available && r.Hint != "" { + token = r.TokenStatus + " — " + r.Hint + } + // Write the label and value as separate %s args rather than one combined + // literal. A single label-colon-value literal trips the public-content + // credential scanner as a false-positive credential assignment; splitting + // the args avoids it while producing identical output. + fmt.Fprintf(w, "%s%s\n", "Token: ", token) +} diff --git a/cmd/whoami/whoami_test.go b/cmd/whoami/whoami_test.go new file mode 100644 index 00000000..d3a7ec80 --- /dev/null +++ b/cmd/whoami/whoami_test.go @@ -0,0 +1,258 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package whoami + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identitydiag" +) + +func TestResolveSource(t *testing.T) { + tests := []struct { + name string + changedAs bool + flagAs core.Identity + autoDetected bool + strictForced core.Identity + want string + }{ + {"explicit flag user", true, core.AsUser, false, "", "flag"}, + {"explicit flag bot", true, core.AsBot, false, "", "flag"}, + {"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto-detect"}, + {"auto detected", false, "", true, "", "auto-detect"}, + {"strict mode", false, "", false, core.AsBot, "strict-mode"}, + {"default-as", false, "", false, "", "default-as"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced) + if got != tt.want { + t.Errorf("resolveSource() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildResult_UserValid(t *testing.T) { + cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto} + diag := identitydiag.Result{ + User: identitydiag.Identity{Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"}, + } + r := buildResult(cfg, core.AsUser, "auto-detect", diag) + + if r.Identity != "user" || r.IdentitySource != "auto-detect" { + t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource) + } + if !r.Available || r.TokenStatus != "valid" { + t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus) + } + if r.OpenID != "ou_x" || r.UserName != "Alice" { + t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName) + } + if r.Hint != "" { + t.Fatalf("hint = %q, want empty", r.Hint) + } + if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark { + t.Fatalf("app context = %#v", r) + } +} + +func TestBuildResult_UserMissingToken(t *testing.T) { + cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark} + diag := identitydiag.Result{ + User: identitydiag.Identity{Available: false, TokenStatus: ""}, // never logged in + } + r := buildResult(cfg, core.AsUser, "auto-detect", diag) + + if r.Available { + t.Fatalf("available = true, want false") + } + if r.TokenStatus != "missing" { + t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus) + } + if r.Hint == "" { + t.Fatalf("hint empty, want guidance") + } + if r.DefaultAs != "auto" { + t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs) + } +} + +func TestBuildResult_BotReady(t *testing.T) { + cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot} + diag := identitydiag.Result{ + Bot: identitydiag.Identity{Available: true, Status: "ready"}, + } + r := buildResult(cfg, core.AsBot, "default-as", diag) + + if r.Identity != "bot" || r.IdentitySource != "default-as" { + t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource) + } + if !r.Available || r.TokenStatus != "ready" { + t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus) + } + if r.OpenID != "" || r.UserName != "" { + t.Fatalf("bot must not carry openId/userName: %#v", r) + } + if r.Hint != "" { + t.Fatalf("hint = %q, want empty", r.Hint) + } +} + +func TestBuildResult_BotNotConfigured(t *testing.T) { + cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu} + diag := identitydiag.Result{ + Bot: identitydiag.Identity{Available: false, Status: "not_configured"}, + } + r := buildResult(cfg, core.AsBot, "auto-detect", diag) + + if r.Available { + t.Fatalf("available = true, want false") + } + if r.TokenStatus != "not_configured" { + t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus) + } + if r.Hint == "" { + t.Fatalf("hint empty, want guidance") + } +} + +func TestFormatPretty_User(t *testing.T) { + var buf bytes.Buffer + formatPretty(&buf, &whoamiResult{ + Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark, + Identity: "user", IdentitySource: "auto-detect", + Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice", + }) + out := buf.String() + for _, want := range []string{ + "Profile: my-app (cli_x, lark)", + "Identity: user (auto-detect)", + "User: Alice (ou_x)", + "Token: valid", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\n--- got ---\n%s", want, out) + } + } +} + +func TestFormatPretty_BotNoUserLine(t *testing.T) { + var buf bytes.Buffer + formatPretty(&buf, &whoamiResult{ + Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu, + Identity: "bot", IdentitySource: "default-as", + Available: true, TokenStatus: "ready", + }) + out := buf.String() + if strings.Contains(out, "User:") { + t.Errorf("bot output must not contain User: line\n%s", out) + } + if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") { + t.Errorf("unexpected bot output:\n%s", out) + } +} + +func TestFormatPretty_UnavailableShowsHint(t *testing.T) { + var buf bytes.Buffer + formatPretty(&buf, &whoamiResult{ + Profile: "p", AppID: "cli_x", Brand: core.BrandLark, + Identity: "user", IdentitySource: "auto-detect", + Available: false, TokenStatus: "missing", + Hint: "No usable user token. Run `lark-cli auth login`.", + }) + out := buf.String() + if !strings.Contains(out, "Token: missing — No usable user token.") { + t.Errorf("expected token line with hint, got:\n%s", out) + } +} + +func TestWhoami_BotJSON(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdWhoami(f) + cmd.SetArgs([]string{"--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + var got whoamiResult + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String()) + } + if got.Identity != "bot" { + t.Fatalf("identity = %q, want bot", got.Identity) + } + if !got.Available || got.TokenStatus != "ready" { + t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus) + } + if got.Profile != "test-profile" { + t.Fatalf("profile = %q, want test-profile", got.Profile) + } + if got.IdentitySource == "" { + t.Fatalf("identitySource empty") + } + if got.OpenID != "" { + t.Fatalf("bot must not carry openId: %q", got.OpenID) + } +} + +func TestWhoami_RejectsInvalidAs(t *testing.T) { + for _, bad := range []string{"admin", "USER", "bogus123", ""} { + t.Run("as="+bad, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + cmd := NewCmdWhoami(f) + cmd.SetArgs([]string{"--as", bad}) + err := cmd.Execute() + if err == nil { + t.Fatalf("Execute() with --as %q = nil, want validation error", bad) + } + // Lock in the typed validation contract: an unsupported identity must + // surface as a *errs.ValidationError on --as, not just any error. + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + if ve.Param != "--as" { + t.Errorf("Param = %q, want %q", ve.Param, "--as") + } + }) + } +} + +func TestWhoami_ConfigErrorPropagates(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + wantErr := fmt.Errorf("boom") + f.Config = func() (*core.CliConfig, error) { return nil, wantErr } + + cmd := NewCmdWhoami(f) + cmd.SetArgs([]string{"--json"}) + err := cmd.Execute() + if err == nil { + t.Fatalf("Execute() error = nil, want propagated config error") + } + // The f.Config() failure must propagate unchanged, not be masked by a later + // command-execution error. + if !errors.Is(err, wantErr) { + t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr) + } +} diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index 7f4559e8..95f305ae 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -33,6 +33,7 @@ lark-cli config init --new | 按业务域授权 | `lark-cli auth login --domain docs --domain drive --no-wait --json`;`--domain` 可重复,也可用逗号分隔 | | 指定单个 scope 授权 | `lark-cli auth login --scope "" --no-wait --json` | | 检查当前登录态、是谁登录、token 是否有效 | `lark-cli auth status --json --verify`;回答时引用 `identity`、`verified`、`identities.user.status`、`identities.user.userName`、`identities.user.openId`(用户 open id)、`identities.user.tokenStatus`、`identities.user.scope` | +| 快速看当前生效身份(人类可读) | `lark-cli whoami`;聚焦实际生效的那一个身份(走 `--as`/`default-as`/strict-mode 解析,可能与 `auth status` 的「第一个可用身份」不同),含当前 profile。脚本/agent 取结构化结果加 `--json`(字段 `identity`、`identitySource`、`available`、`tokenStatus`)。深度诊断 / 服务器校验仍用 `auth status --json --verify` | | 退出当前机器的用户登录态 | `lark-cli auth logout --json`;`loggedOut:true` 表示注销成功 | | bot 缺少权限 | 不要执行 `auth login`;引导用户在开发者后台开通 bot scope,优先复用错误里的 `console_url` | | 取消用户对应用的全部服务端授权 | `auth logout` 只清本机登录态;服务端授权需用户在飞书授权管理页取消 |