Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4dd8a3a
feat: add LARKSUITE_CLI_PROFILE session env with flag precedence
luozhixiong01 Jul 6, 2026
c8eb7a5
feat: add profile selection error subtypes and machine-readable fields
luozhixiong01 Jul 6, 2026
6adeb0a
feat: add IdentitySelection type for explainable credential selection
luozhixiong01 Jul 6, 2026
ab00ddc
feat: unify credential selection with profile conflict detection
luozhixiong01 Jul 6, 2026
221dcc1
fix(credential): gate success-account direct-credential treatment on …
luozhixiong01 Jul 6, 2026
7c13fb1
test(credential): lock profile_secret_invalid against secret-bearing …
luozhixiong01 Jul 6, 2026
65b35be
feat: surface credentialSource and directCredentialEnv in whoami
luozhixiong01 Jul 6, 2026
ff3ac0e
refactor: drop whoami suggestion field and IdentitySelection.Suggestion
luozhixiong01 Jul 6, 2026
826d807
docs: add profile selection help to profile and whoami commands
luozhixiong01 Jul 6, 2026
acc835d
docs: add profile selection entry hint to lark-shared skill
luozhixiong01 Jul 6, 2026
5f2ff35
fix(credential): add credential_source to config errors and report pr…
luozhixiong01 Jul 6, 2026
c71434d
docs: forbid hollow identity promises in lark-shared profile hint
luozhixiong01 Jul 6, 2026
92c43b3
docs: broaden lark-shared trigger to profile selection and add sessio…
luozhixiong01 Jul 6, 2026
66100b7
docs: trim lark-shared profile rule to lark-cli scope, drop agent-she…
luozhixiong01 Jul 6, 2026
62405d6
docs: clarify whoami vs auth status boundary in lark-shared profile rule
luozhixiong01 Jul 6, 2026
feaf123
docs: use imperative one-line whoami vs auth status routing boundary
luozhixiong01 Jul 6, 2026
40b3087
feat: distinguish auth status from whoami identity in help and skill
luozhixiong01 Jul 6, 2026
bb82140
docs: guide per-command LARKSUITE_CLI_PROFILE prefix for non-persiste…
luozhixiong01 Jul 7, 2026
6fd370a
docs: point to auth status --json --verify for token validity checks
luozhixiong01 Jul 7, 2026
5ef56f0
docs: require auth status --json --verify for login/token checks
luozhixiong01 Jul 7, 2026
d87a695
test(credential): use recognized placeholders for secret fixtures
luozhixiong01 Jul 7, 2026
87e989d
docs(lark-shared): refine profile-selection guidance
luozhixiong01 Jul 7, 2026
74629fd
fix(credential): propagate malformed-config error for explicit profile
luozhixiong01 Jul 7, 2026
0547d40
feat(profile): distinguish saved default from effective identity
luozhixiong01 Jul 7, 2026
c76cac8
docs(lark-shared): write profile-selection guidance in Chinese
luozhixiong01 Jul 8, 2026
c15b811
fix(credential): surface malformed config on the config-default path
luozhixiong01 Jul 13, 2026
e6f8b8a
fix(credential): make profile arbitration explicit
luozhixiong01 Jul 14, 2026
a209f30
test: use recognized credential placeholders
luozhixiong01 Jul 14, 2026
00d8c07
docs(lark-shared): restore the trigger description to main's version
luozhixiong01 Jul 14, 2026
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
3 changes: 3 additions & 0 deletions cmd/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
cmd := &cobra.Command{
Use: "status",
Short: "View current auth status",
Long: `Show OAuth user login, token validity, and granted scopes.
For token-validity checks, run lark-cli auth status --json --verify.
This is not profile/app selection diagnostics; use lark-cli whoami for the effective app/profile identity used by an invocation.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
Expand Down
65 changes: 65 additions & 0 deletions cmd/auth/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,35 @@
package auth

import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"

extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
)

func TestAuthStatusHelpDistinguishesFromWhoami(t *testing.T) {
cmd := NewCmdAuthStatus(nil, nil)
for _, want := range []string{
"OAuth user login",
"auth status --json --verify",
"not profile/app selection diagnostics",
"lark-cli whoami",
} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("auth status --help Long missing %q; got:\n%s", want, cmd.Long)
}
}
}

func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
Expand Down Expand Up @@ -79,6 +99,51 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
}
}

type fixedStatusAccountResolver struct {
account *credential.Account
}

func (r *fixedStatusAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return r.account, nil
}

func TestAuthStatus_AllowsMatchingAppIDOnlySelectedProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "cli_a")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}

config := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, stdout, _, _ := cmdutil.TestFactory(t, config)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
&fixedStatusAccountResolver{account: credential.AccountFromCliConfig(config)},
nil,
nil,
).WithProfileFromFlag("tenant_a")

cmd := NewCmdAuth(f)
cmd.SetArgs([]string{"status", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status should use the selected built-in profile: %v", err)
}
if strings.Contains(stdout.String(), "credentials are provided externally") {
t.Fatalf("matching APP_ID-only env was misclassified as external:\n%s", stdout.String())
}
}

type statusOutput struct {
Identity string `json:"identity"`
Verified *bool `json:"verified"`
Expand Down
12 changes: 11 additions & 1 deletion cmd/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ package cmd
import (
"errors"
"io"
"os"

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

Expand All @@ -26,5 +28,13 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
return cmdutil.InvocationContext{}, err
}
return cmdutil.InvocationContext{Profile: globals.Profile}, nil

profileFromFlag := globals.Profile != ""
if !profileFromFlag {
globals.Profile = os.Getenv(envvars.CliProfile)
}
return cmdutil.InvocationContext{
Profile: globals.Profile,
ProfileFromFlag: profileFromFlag,
}, nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
48 changes: 47 additions & 1 deletion cmd/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

package cmd

import "testing"
import (
"testing"

"github.com/larksuite/cli/internal/envvars"
)

func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
Expand Down Expand Up @@ -70,3 +74,45 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
}
}

func TestBootstrapProfileEnvFallback(t *testing.T) {
t.Run("flag wins over env", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"--profile", "tenant_flag", "whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "tenant_flag" {
t.Errorf("got %q, want tenant_flag", inv.Profile)
}
if !inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = false, want true")
}
})
t.Run("env used when flag absent", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "tenant_env" {
t.Errorf("got %q, want tenant_env", inv.Profile)
}
if inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = true, want false")
}
})
t.Run("empty when neither set", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "")
inv, err := BootstrapInvocationContext([]string{"whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "" {
t.Errorf("got %q, want empty", inv.Profile)
}
if inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = true, want false")
}
})
}
10 changes: 10 additions & 0 deletions cmd/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
}
}

func TestConfigShowHelpClarifiesSavedConfig(t *testing.T) {
cmd := NewCmdConfigShow(nil, nil)
if !strings.Contains(cmd.Short, "saved config") {
t.Errorf("config show short = %q, want saved config", cmd.Short)
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("config show help missing whoami route")
}
}

func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

Expand Down
3 changes: 2 additions & 1 deletion cmd/config/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *

cmd := &cobra.Command{
Use: "show",
Short: "Show current configuration",
Short: "Show saved config",
Long: "Shows saved config. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
Expand Down
15 changes: 8 additions & 7 deletions cmd/profile/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type profileListItem struct {
Name string `json:"name"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
Active bool `json:"active"`
Default bool `json:"default"`
User string `json:"user,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
}
Expand All @@ -30,7 +30,8 @@ type profileListItem struct {
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List all profiles",
Short: "List saved profiles",
Long: "Lists saved profiles. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
RunE: func(cmd *cobra.Command, args []string) error {
return profileListRun(f)
},
Expand All @@ -53,7 +54,7 @@ func profileListRun(f *cmdutil.Factory) error {
return nil
}

// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
// Intentionally uses "" to show the saved default profile, not the ephemeral --profile override.
currentApp := multi.CurrentAppConfig("")
currentName := ""
if currentApp != nil {
Expand All @@ -66,10 +67,10 @@ func profileListRun(f *cmdutil.Factory) error {
name := app.ProfileName()

item := profileListItem{
Name: name,
AppID: app.AppId,
Brand: app.Brand,
Active: name == currentName,
Name: name,
AppID: app.AppId,
Brand: app.Brand,
Default: name == currentName,
}

if len(app.Users) > 0 {
Expand Down
11 changes: 11 additions & 0 deletions cmd/profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage configuration profiles",
Long: `Profiles are named app identities managed by lark-cli.

Profile selection:
lark-cli whoami --json Show the app/profile lark-cli is using now.
lark-cli auth status --json Verify OAuth login and token state.
--profile <name> Use a profile for this command only.
LARKSUITE_CLI_PROFILE Use a profile for the current shell / agent session.
config show / profile list Inspect saved config, not current usage.
unset LARKSUITE_CLI_PROFILE Clear the session profile and fall back to direct app env or configured default.

A selected profile takes precedence over matching direct env credentials and tokens.`,
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetTips(cmd, []string{
Expand Down
48 changes: 44 additions & 4 deletions cmd/profile/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,14 +306,21 @@ func TestProfileListRun_OutputsProfiles(t *testing.T) {
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
}
raw := stdout.String()
if strings.Contains(raw, `"active"`) {
t.Fatalf("profile list output contains legacy active field: %s", raw)
}
if !strings.Contains(raw, `"default"`) {
t.Fatalf("profile list output missing default field: %s", raw)
}
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2", len(got))
}
if got[0].Name != "default" || !got[0].Active {
t.Fatalf("got[0] = %#v, want active default profile", got[0])
if got[0].Name != "default" || !got[0].Default {
t.Fatalf("got[0] = %#v, want configured default profile", got[0])
}
if got[1].Name != "target" || got[1].Active {
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
if got[1].Name != "target" || got[1].Default {
t.Fatalf("got[1] = %#v, want non-default target profile", got[1])
}
}

Expand Down Expand Up @@ -627,6 +634,39 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
})
}

// TestProfileHelpHasSelectionSection asserts `profile --help` documents the
// per-invocation flag and session-scoped env var for selecting a profile, so
// users and AI agents can find LARKSUITE_CLI_PROFILE without reading source.
func TestProfileHelpHasSelectionSection(t *testing.T) {
cmd := NewCmdProfile(nil)
if !strings.Contains(cmd.Long, "Profile selection:") {
t.Errorf("profile --help missing Profile selection section")
}
if !strings.Contains(cmd.Long, "LARKSUITE_CLI_PROFILE") {
t.Errorf("profile --help missing LARKSUITE_CLI_PROFILE")
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("profile --help missing whoami identity route")
}
if !strings.Contains(cmd.Long, "config show / profile list") {
t.Errorf("profile --help missing saved-config boundary")
}
const precedence = "A selected profile takes precedence over matching direct env credentials and tokens."
if !strings.Contains(cmd.Long, precedence) {
t.Errorf("profile --help missing precedence statement %q", precedence)
}
}

func TestProfileListHelpClarifiesSavedProfiles(t *testing.T) {
cmd := NewCmdProfileList(nil)
if !strings.Contains(cmd.Short, "saved profiles") {
t.Errorf("profile list short = %q, want saved profiles", cmd.Short)
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("profile list help missing whoami route")
}
}

func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
Expand Down
Loading
Loading