From 91acb47610e6c9f04b854d456874d5717590dd86 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 11 Mar 2026 15:12:03 -0400 Subject: [PATCH 1/4] feat: add whoami command Adds `ldcli whoami` which calls /api/v2/caller-identity to show information about the identity associated with the current access token (token name, auth kind, member ID, scopes, etc.). Co-Authored-By: Claude Sonnet 4.6 --- cmd/root.go | 2 ++ cmd/whoami/whoami.go | 60 +++++++++++++++++++++++++++++++++++++++ cmd/whoami/whoami_test.go | 55 +++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 cmd/whoami/whoami.go create mode 100644 cmd/whoami/whoami_test.go diff --git a/cmd/root.go b/cmd/root.go index 99a23158..60d93d6c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( memberscmd "github.com/launchdarkly/ldcli/cmd/members" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" + whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" "github.com/launchdarkly/ldcli/internal/dev_server" @@ -205,6 +206,7 @@ func NewRootCommand( cmd.AddCommand(resourcecmd.NewResourcesCmd()) cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) + cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient)) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) // add non-generated commands diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go new file mode 100644 index 00000000..187331de --- /dev/null +++ b/cmd/whoami/whoami.go @@ -0,0 +1,60 @@ +package whoami + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/errors" + "github.com/launchdarkly/ldcli/internal/output" + "github.com/launchdarkly/ldcli/internal/resources" +) + +func NewWhoAmICmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Args: validators.Validate(), + Long: "Show information about the identity associated with the current access token.", + RunE: makeRequest(client), + Short: "Show current caller identity", + Use: "whoami", + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} + +func makeRequest(client resources.Client) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + path, _ := url.JoinPath( + viper.GetString(cliflags.BaseURIFlag), + "api/v2/caller-identity", + ) + res, err := client.MakeRequest( + viper.GetString(cliflags.AccessTokenFlag), + "GET", + path, + "application/json", + nil, + nil, + false, + ) + if err != nil { + return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) + } + + out, err := output.CmdOutputSingular(viper.GetString(cliflags.OutputFlag), res, output.ConfigPlaintextOutputFn) + if err != nil { + return errors.NewError(err.Error()) + } + + fmt.Fprint(cmd.OutOrStdout(), out+"\n") + + return nil + } +} diff --git a/cmd/whoami/whoami_test.go b/cmd/whoami/whoami_test.go new file mode 100644 index 00000000..a062d596 --- /dev/null +++ b/cmd/whoami/whoami_test.go @@ -0,0 +1,55 @@ +package whoami_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/cmd" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +func TestWhoAmI(t *testing.T) { + t.Run("with valid token prints caller identity", func(t *testing.T) { + mockClient := &resources.MockClient{ + Response: []byte(`{"tokenName": "my-token", "authKind": "token", "memberId": "abc123"}`), + } + args := []string{ + "whoami", + "--access-token", "abcd1234", + } + + output, err := cmd.CallCmd( + t, + cmd.APIClients{ResourcesClient: mockClient}, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "my-token") + }) + + t.Run("with --output json returns raw JSON", func(t *testing.T) { + mockClient := &resources.MockClient{ + Response: []byte(`{"tokenName": "my-token", "authKind": "token", "memberId": "abc123"}`), + } + args := []string{ + "whoami", + "--access-token", "abcd1234", + "--output", "json", + } + + output, err := cmd.CallCmd( + t, + cmd.APIClients{ResourcesClient: mockClient}, + analytics.NoopClientFn{}.Tracker(), + args, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"tokenName": "my-token"`) + }) +} From 45d91336388d7ad6a22b565d1362d7e92112a88b Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 11 Mar 2026 15:32:58 -0400 Subject: [PATCH 2/4] feat: whoami reads token from config, not CLI flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes --access-token requirement from whoami — the command reads the token from config/env (like ldcli login sets up), matching the pattern of gh auth status and similar commands. Also hides --access-token, --base-uri, and --analytics-opt-out from whoami's help output since they're not relevant to this command. Co-Authored-By: Claude Sonnet 4.6 --- cmd/root.go | 1 + cmd/whoami/whoami.go | 32 +++++++++++++++++++++++++++++--- cmd/whoami/whoami_test.go | 30 ++++++++++++++++++------------ 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 60d93d6c..7ee6b086 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -101,6 +101,7 @@ func NewRootCommand( "config", "help", "login", + "whoami", } { if cmd.HasParent() && cmd.Parent().Name() == name { cmd.DisableFlagParsing = true diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go index 187331de..97a03366 100644 --- a/cmd/whoami/whoami.go +++ b/cmd/whoami/whoami.go @@ -9,7 +9,6 @@ import ( "github.com/launchdarkly/ldcli/cmd/cliflags" resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" - "github.com/launchdarkly/ldcli/cmd/validators" "github.com/launchdarkly/ldcli/internal/errors" "github.com/launchdarkly/ldcli/internal/output" "github.com/launchdarkly/ldcli/internal/resources" @@ -17,7 +16,7 @@ import ( func NewWhoAmICmd(client resources.Client) *cobra.Command { cmd := &cobra.Command{ - Args: validators.Validate(), + Args: cobra.NoArgs, Long: "Show information about the identity associated with the current access token.", RunE: makeRequest(client), Short: "Show current caller identity", @@ -26,17 +25,44 @@ func NewWhoAmICmd(client resources.Client) *cobra.Command { cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + // Hide flags that don't apply to whoami from its help output. + // Access token and base URI are read from config; analytics opt-out is not relevant. + hiddenInHelp := []string{ + cliflags.AccessTokenFlag, + cliflags.BaseURIFlag, + cliflags.AnalyticsOptOut, + } + defaultHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(c *cobra.Command, args []string) { + for _, name := range hiddenInHelp { + if f := c.Root().PersistentFlags().Lookup(name); f != nil { + f.Hidden = true + } + } + defaultHelp(c, args) + for _, name := range hiddenInHelp { + if f := c.Root().PersistentFlags().Lookup(name); f != nil { + f.Hidden = false + } + } + }) + return cmd } func makeRequest(client resources.Client) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { + accessToken := viper.GetString(cliflags.AccessTokenFlag) + if accessToken == "" { + return errors.NewError("no access token configured. Run `ldcli login` or set LD_ACCESS_TOKEN") + } + path, _ := url.JoinPath( viper.GetString(cliflags.BaseURIFlag), "api/v2/caller-identity", ) res, err := client.MakeRequest( - viper.GetString(cliflags.AccessTokenFlag), + accessToken, "GET", path, "application/json", diff --git a/cmd/whoami/whoami_test.go b/cmd/whoami/whoami_test.go index a062d596..b892deb0 100644 --- a/cmd/whoami/whoami_test.go +++ b/cmd/whoami/whoami_test.go @@ -12,41 +12,47 @@ import ( ) func TestWhoAmI(t *testing.T) { - t.Run("with valid token prints caller identity", func(t *testing.T) { + t.Run("with configured token prints caller identity", func(t *testing.T) { mockClient := &resources.MockClient{ Response: []byte(`{"tokenName": "my-token", "authKind": "token", "memberId": "abc123"}`), } - args := []string{ - "whoami", - "--access-token", "abcd1234", - } + + t.Setenv("LD_ACCESS_TOKEN", "abcd1234") output, err := cmd.CallCmd( t, cmd.APIClients{ResourcesClient: mockClient}, analytics.NoopClientFn{}.Tracker(), - args, + []string{"whoami"}, ) require.NoError(t, err) assert.Contains(t, string(output), "my-token") }) + t.Run("without configured token returns helpful error", func(t *testing.T) { + _, err := cmd.CallCmd( + t, + cmd.APIClients{}, + analytics.NoopClientFn{}.Tracker(), + []string{"whoami"}, + ) + + require.ErrorContains(t, err, "no access token configured") + }) + t.Run("with --output json returns raw JSON", func(t *testing.T) { mockClient := &resources.MockClient{ Response: []byte(`{"tokenName": "my-token", "authKind": "token", "memberId": "abc123"}`), } - args := []string{ - "whoami", - "--access-token", "abcd1234", - "--output", "json", - } + + t.Setenv("LD_ACCESS_TOKEN", "abcd1234") output, err := cmd.CallCmd( t, cmd.APIClients{ResourcesClient: mockClient}, analytics.NoopClientFn{}.Tracker(), - args, + []string{"whoami", "--output", "json"}, ) require.NoError(t, err) From 317f1aff17d36c929018a476e2aba8bcefddc78b Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 11 Mar 2026 15:39:56 -0400 Subject: [PATCH 3/4] feat: enrich whoami output with member name, email, and role Fetches /api/v2/members/{id} after caller-identity when a memberId is present, giving plaintext output like: Ariel Flores Role: admin Token: my-api-token (personal) JSON output remains the raw caller-identity response for scripting. Co-Authored-By: Claude Sonnet 4.6 --- cmd/whoami/whoami.go | 103 +++++++++++++++++++++++++++++++------- cmd/whoami/whoami_test.go | 56 +++++++++++++++++++-- 2 files changed, 137 insertions(+), 22 deletions(-) diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go index 97a03366..09c8d598 100644 --- a/cmd/whoami/whoami.go +++ b/cmd/whoami/whoami.go @@ -1,8 +1,10 @@ package whoami import ( + "encoding/json" "fmt" "net/url" + "strings" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -14,6 +16,29 @@ import ( "github.com/launchdarkly/ldcli/internal/resources" ) +type callerIdentity struct { + AccountID string `json:"accountId"` + AuthKind string `json:"authKind"` + ClientID string `json:"clientId"` + EnvironmentID string `json:"environmentId"` + EnvironmentName string `json:"environmentName"` + MemberID string `json:"memberId"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + Scopes []string `json:"scopes"` + ServiceToken bool `json:"serviceToken"` + TokenID string `json:"tokenId"` + TokenKind string `json:"tokenKind"` + TokenName string `json:"tokenName"` +} + +type memberSummary struct { + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Role string `json:"role"` +} + func NewWhoAmICmd(client resources.Client) *cobra.Command { cmd := &cobra.Command{ Args: cobra.NoArgs, @@ -57,30 +82,74 @@ func makeRequest(client resources.Client) func(*cobra.Command, []string) error { return errors.NewError("no access token configured. Run `ldcli login` or set LD_ACCESS_TOKEN") } - path, _ := url.JoinPath( - viper.GetString(cliflags.BaseURIFlag), - "api/v2/caller-identity", - ) - res, err := client.MakeRequest( - accessToken, - "GET", - path, - "application/json", - nil, - nil, - false, - ) + baseURI := viper.GetString(cliflags.BaseURIFlag) + outputKind := viper.GetString(cliflags.OutputFlag) + + identityPath, _ := url.JoinPath(baseURI, "api/v2/caller-identity") + identityRes, err := client.MakeRequest(accessToken, "GET", identityPath, "application/json", nil, nil, false) if err != nil { - return output.NewCmdOutputError(err, viper.GetString(cliflags.OutputFlag)) + return output.NewCmdOutputError(err, outputKind) } - out, err := output.CmdOutputSingular(viper.GetString(cliflags.OutputFlag), res, output.ConfigPlaintextOutputFn) - if err != nil { + // For JSON output, return the raw caller-identity response. + if outputKind == "json" { + out, err := output.CmdOutputSingular(outputKind, identityRes, output.ConfigPlaintextOutputFn) + if err != nil { + return errors.NewError(err.Error()) + } + fmt.Fprint(cmd.OutOrStdout(), out+"\n") + return nil + } + + var identity callerIdentity + if err := json.Unmarshal(identityRes, &identity); err != nil { return errors.NewError(err.Error()) } - fmt.Fprint(cmd.OutOrStdout(), out+"\n") + // Fetch member info for a richer plaintext display. + var member *memberSummary + if identity.MemberID != "" { + memberPath, _ := url.JoinPath(baseURI, "api/v2/members", identity.MemberID) + memberRes, err := client.MakeRequest(accessToken, "GET", memberPath, "application/json", nil, nil, false) + if err == nil { + var m memberSummary + if json.Unmarshal(memberRes, &m) == nil { + member = &m + } + } + } + fmt.Fprint(cmd.OutOrStdout(), formatPlaintext(identity, member)+"\n") return nil } } + +func formatPlaintext(identity callerIdentity, member *memberSummary) string { + var sb strings.Builder + + if member != nil { + name := strings.TrimSpace(member.FirstName + " " + member.LastName) + if name != "" { + fmt.Fprintf(&sb, "%s <%s>\n", name, member.Email) + } else { + fmt.Fprintf(&sb, "%s\n", member.Email) + } + fmt.Fprintf(&sb, "Role: %s\n", member.Role) + } + + tokenKind := identity.TokenKind + if identity.ServiceToken { + tokenKind = "service token" + } + if identity.TokenName != "" { + fmt.Fprintf(&sb, "Token: %s (%s)\n", identity.TokenName, tokenKind) + } else if identity.ClientID != "" { + fmt.Fprintf(&sb, "Token: %s (%s)\n", identity.ClientID, tokenKind) + } + + if identity.AccountID != "" { + fmt.Fprintf(&sb, "Account: %s\n", identity.AccountID) + } + + return strings.TrimRight(sb.String(), "\n") +} diff --git a/cmd/whoami/whoami_test.go b/cmd/whoami/whoami_test.go index b892deb0..5e8866ba 100644 --- a/cmd/whoami/whoami_test.go +++ b/cmd/whoami/whoami_test.go @@ -1,6 +1,7 @@ package whoami_test import ( + "net/url" "testing" "github.com/stretchr/testify/assert" @@ -11,10 +12,54 @@ import ( "github.com/launchdarkly/ldcli/internal/resources" ) +// sequentialMockClient returns responses in order, one per call. +type sequentialMockClient struct { + responses [][]byte + callIndex int +} + +var _ resources.Client = &sequentialMockClient{} + +func (c *sequentialMockClient) MakeRequest(_, _, _ string, _ string, _ url.Values, _ []byte, _ bool) ([]byte, error) { + if c.callIndex >= len(c.responses) { + return nil, nil + } + res := c.responses[c.callIndex] + c.callIndex++ + return res, nil +} + +func (c *sequentialMockClient) MakeUnauthenticatedRequest(_ string, _ string, _ []byte) ([]byte, error) { + return nil, nil +} + func TestWhoAmI(t *testing.T) { - t.Run("with configured token prints caller identity", func(t *testing.T) { + t.Run("shows member name, email, role, and token", func(t *testing.T) { + mockClient := &sequentialMockClient{ + responses: [][]byte{ + []byte(`{"memberId": "abc123", "tokenName": "my-token", "tokenKind": "personal", "accountId": "acct1"}`), + []byte(`{"_id": "abc123", "email": "ariel@acme.com", "firstName": "Ariel", "lastName": "Flores", "role": "admin"}`), + }, + } + + t.Setenv("LD_ACCESS_TOKEN", "abcd1234") + + output, err := cmd.CallCmd( + t, + cmd.APIClients{ResourcesClient: mockClient}, + analytics.NoopClientFn{}.Tracker(), + []string{"whoami"}, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Ariel Flores ") + assert.Contains(t, string(output), "Role: admin") + assert.Contains(t, string(output), "Token: my-token (personal)") + }) + + t.Run("without member ID shows token info only", func(t *testing.T) { mockClient := &resources.MockClient{ - Response: []byte(`{"tokenName": "my-token", "authKind": "token", "memberId": "abc123"}`), + Response: []byte(`{"tokenName": "sdk-key", "tokenKind": "server", "accountId": "acct1"}`), } t.Setenv("LD_ACCESS_TOKEN", "abcd1234") @@ -27,7 +72,8 @@ func TestWhoAmI(t *testing.T) { ) require.NoError(t, err) - assert.Contains(t, string(output), "my-token") + assert.Contains(t, string(output), "Token: sdk-key (server)") + assert.NotContains(t, string(output), "Role:") }) t.Run("without configured token returns helpful error", func(t *testing.T) { @@ -41,9 +87,9 @@ func TestWhoAmI(t *testing.T) { require.ErrorContains(t, err, "no access token configured") }) - t.Run("with --output json returns raw JSON", func(t *testing.T) { + t.Run("with --output json returns raw caller-identity JSON", func(t *testing.T) { mockClient := &resources.MockClient{ - Response: []byte(`{"tokenName": "my-token", "authKind": "token", "memberId": "abc123"}`), + Response: []byte(`{"tokenName": "my-token", "memberId": "abc123"}`), } t.Setenv("LD_ACCESS_TOKEN", "abcd1234") From a084c44ea993d6181ca9754c381fd84524ad84c3 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 11 Mar 2026 15:57:27 -0400 Subject: [PATCH 4/4] feat: add --json flag as shorthand for --output json (#656) Co-Authored-By: Claude Sonnet 4.6 --- cmd/whoami/whoami.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go index 09c8d598..bb30b57f 100644 --- a/cmd/whoami/whoami.go +++ b/cmd/whoami/whoami.go @@ -83,7 +83,7 @@ func makeRequest(client resources.Client) func(*cobra.Command, []string) error { } baseURI := viper.GetString(cliflags.BaseURIFlag) - outputKind := viper.GetString(cliflags.OutputFlag) + outputKind := cliflags.GetOutputKind(cmd) identityPath, _ := url.JoinPath(baseURI, "api/v2/caller-identity") identityRes, err := client.MakeRequest(accessToken, "GET", identityPath, "application/json", nil, nil, false)