Skip to content

Commit d2cca65

Browse files
apucacaoclaude
andauthored
feat: add whoami command (#657)
* 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 <noreply@anthropic.com> * feat: whoami reads token from config, not CLI flag 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 <noreply@anthropic.com> * 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 <ariel@acme.com> Role: admin Token: my-api-token (personal) JSON output remains the raw caller-identity response for scripting. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add --json flag as shorthand for --output json (#656) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: show organization name in whoami output --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 98700e4 commit d2cca65

3 files changed

Lines changed: 313 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
resourcecmd "github.com/launchdarkly/ldcli/cmd/resources"
2727
signupcmd "github.com/launchdarkly/ldcli/cmd/signup"
2828
sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps"
29+
whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami"
2930
"github.com/launchdarkly/ldcli/internal/analytics"
3031
"github.com/launchdarkly/ldcli/internal/config"
3132
"github.com/launchdarkly/ldcli/internal/dev_server"
@@ -131,6 +132,7 @@ func NewRootCommand(
131132
"help",
132133
"login",
133134
"signup",
135+
"whoami",
134136
} {
135137
if cmd.HasParent() && cmd.Parent().Name() == name {
136138
cmd.DisableFlagParsing = true
@@ -257,6 +259,7 @@ func NewRootCommand(
257259
cmd.AddCommand(resourcecmd.NewResourcesCmd())
258260
cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient))
259261
cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn))
262+
cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient))
260263
resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn)
261264

262265
// add non-generated commands

cmd/whoami/whoami.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package whoami
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/spf13/viper"
11+
12+
"github.com/launchdarkly/ldcli/cmd/cliflags"
13+
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
14+
"github.com/launchdarkly/ldcli/internal/errors"
15+
"github.com/launchdarkly/ldcli/internal/output"
16+
"github.com/launchdarkly/ldcli/internal/resources"
17+
)
18+
19+
type callerIdentity struct {
20+
AccountID string `json:"accountId"`
21+
AuthKind string `json:"authKind"`
22+
ClientID string `json:"clientId"`
23+
EnvironmentID string `json:"environmentId"`
24+
EnvironmentName string `json:"environmentName"`
25+
MemberID string `json:"memberId"`
26+
ProjectID string `json:"projectId"`
27+
ProjectName string `json:"projectName"`
28+
Scopes []string `json:"scopes"`
29+
ServiceToken bool `json:"serviceToken"`
30+
TokenID string `json:"tokenId"`
31+
TokenKind string `json:"tokenKind"`
32+
TokenName string `json:"tokenName"`
33+
}
34+
35+
type memberSummary struct {
36+
Email string `json:"email"`
37+
FirstName string `json:"firstName"`
38+
LastName string `json:"lastName"`
39+
Role string `json:"role"`
40+
}
41+
42+
type accountSummary struct {
43+
Organization string `json:"organization"`
44+
}
45+
46+
func NewWhoAmICmd(client resources.Client) *cobra.Command {
47+
cmd := &cobra.Command{
48+
Args: cobra.NoArgs,
49+
Long: "Show information about the identity associated with the current access token.",
50+
RunE: makeRequest(client),
51+
Short: "Show current caller identity",
52+
Use: "whoami",
53+
}
54+
55+
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())
56+
57+
// Hide flags that don't apply to whoami from its help output.
58+
// Access token and base URI are read from config; analytics opt-out is not relevant.
59+
hiddenInHelp := []string{
60+
cliflags.AccessTokenFlag,
61+
cliflags.BaseURIFlag,
62+
cliflags.AnalyticsOptOut,
63+
}
64+
defaultHelp := cmd.HelpFunc()
65+
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
66+
for _, name := range hiddenInHelp {
67+
if f := c.Root().PersistentFlags().Lookup(name); f != nil {
68+
f.Hidden = true
69+
}
70+
}
71+
defaultHelp(c, args)
72+
for _, name := range hiddenInHelp {
73+
if f := c.Root().PersistentFlags().Lookup(name); f != nil {
74+
f.Hidden = false
75+
}
76+
}
77+
})
78+
79+
return cmd
80+
}
81+
82+
func makeRequest(client resources.Client) func(*cobra.Command, []string) error {
83+
return func(cmd *cobra.Command, args []string) error {
84+
accessToken := viper.GetString(cliflags.AccessTokenFlag)
85+
if accessToken == "" {
86+
return errors.NewError("no access token configured. Run `ldcli login` or set LD_ACCESS_TOKEN")
87+
}
88+
89+
baseURI := viper.GetString(cliflags.BaseURIFlag)
90+
outputKind := cliflags.GetOutputKind(cmd)
91+
92+
identityPath, _ := url.JoinPath(baseURI, "api/v2/caller-identity")
93+
identityRes, err := client.MakeRequest(accessToken, "GET", identityPath, "application/json", nil, nil, false)
94+
if err != nil {
95+
return output.NewCmdOutputError(err, outputKind)
96+
}
97+
98+
// For JSON output, return the raw caller-identity response.
99+
if outputKind == "json" {
100+
out, err := output.CmdOutputSingular(outputKind, identityRes, output.ConfigPlaintextOutputFn)
101+
if err != nil {
102+
return errors.NewError(err.Error())
103+
}
104+
fmt.Fprint(cmd.OutOrStdout(), out+"\n")
105+
return nil
106+
}
107+
108+
var identity callerIdentity
109+
if err := json.Unmarshal(identityRes, &identity); err != nil {
110+
return errors.NewError(err.Error())
111+
}
112+
113+
// Fetch member info for a richer plaintext display.
114+
var member *memberSummary
115+
if identity.MemberID != "" {
116+
memberPath, _ := url.JoinPath(baseURI, "api/v2/members", identity.MemberID)
117+
memberRes, err := client.MakeRequest(accessToken, "GET", memberPath, "application/json", nil, nil, false)
118+
if err == nil {
119+
var m memberSummary
120+
if json.Unmarshal(memberRes, &m) == nil {
121+
member = &m
122+
}
123+
}
124+
}
125+
126+
// Fetch the account to resolve the organization name. This endpoint is a
127+
// real public-API route but isn't published in the OpenAPI spec, so we
128+
// treat it as best-effort and fall back to the account ID on any failure.
129+
var account *accountSummary
130+
accountPath, _ := url.JoinPath(baseURI, "api/v2/account")
131+
accountRes, err := client.MakeRequest(accessToken, "GET", accountPath, "application/json", nil, nil, false)
132+
if err == nil {
133+
var a accountSummary
134+
if json.Unmarshal(accountRes, &a) == nil {
135+
account = &a
136+
}
137+
}
138+
139+
fmt.Fprint(cmd.OutOrStdout(), formatPlaintext(identity, member, account)+"\n")
140+
return nil
141+
}
142+
}
143+
144+
func formatPlaintext(identity callerIdentity, member *memberSummary, account *accountSummary) string {
145+
var sb strings.Builder
146+
147+
if member != nil {
148+
name := strings.TrimSpace(member.FirstName + " " + member.LastName)
149+
if name != "" {
150+
fmt.Fprintf(&sb, "%s <%s>\n", name, member.Email)
151+
} else {
152+
fmt.Fprintf(&sb, "%s\n", member.Email)
153+
}
154+
fmt.Fprintf(&sb, "Role: %s\n", member.Role)
155+
}
156+
157+
tokenKind := identity.TokenKind
158+
if identity.ServiceToken {
159+
tokenKind = "service token"
160+
}
161+
if identity.TokenName != "" {
162+
fmt.Fprintf(&sb, "Token: %s (%s)\n", identity.TokenName, tokenKind)
163+
} else if identity.ClientID != "" {
164+
fmt.Fprintf(&sb, "Token: %s (%s)\n", identity.ClientID, tokenKind)
165+
}
166+
167+
if account != nil && account.Organization != "" {
168+
if identity.AccountID != "" {
169+
fmt.Fprintf(&sb, "Account: %s (%s)\n", account.Organization, identity.AccountID)
170+
} else {
171+
fmt.Fprintf(&sb, "Account: %s\n", account.Organization)
172+
}
173+
} else if identity.AccountID != "" {
174+
fmt.Fprintf(&sb, "Account: %s\n", identity.AccountID)
175+
}
176+
177+
return strings.TrimRight(sb.String(), "\n")
178+
}

cmd/whoami/whoami_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package whoami_test
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/launchdarkly/ldcli/cmd"
11+
"github.com/launchdarkly/ldcli/internal/analytics"
12+
"github.com/launchdarkly/ldcli/internal/resources"
13+
)
14+
15+
// sequentialMockClient returns responses in order, one per call.
16+
type sequentialMockClient struct {
17+
responses [][]byte
18+
callIndex int
19+
}
20+
21+
var _ resources.Client = &sequentialMockClient{}
22+
23+
func (c *sequentialMockClient) MakeRequest(_, _, _ string, _ string, _ url.Values, _ []byte, _ bool) ([]byte, error) {
24+
if c.callIndex >= len(c.responses) {
25+
return nil, nil
26+
}
27+
res := c.responses[c.callIndex]
28+
c.callIndex++
29+
return res, nil
30+
}
31+
32+
func (c *sequentialMockClient) MakeUnauthenticatedRequest(_ string, _ string, _ []byte) ([]byte, error) {
33+
return nil, nil
34+
}
35+
36+
func TestWhoAmI(t *testing.T) {
37+
t.Run("shows member name, email, role, token, and organization", func(t *testing.T) {
38+
mockClient := &sequentialMockClient{
39+
responses: [][]byte{
40+
[]byte(`{"memberId": "abc123", "tokenName": "my-token", "tokenKind": "personal", "accountId": "acct1"}`),
41+
[]byte(`{"_id": "abc123", "email": "ariel@acme.com", "firstName": "Ariel", "lastName": "Flores", "role": "admin"}`),
42+
[]byte(`{"_id": "acct1", "organization": "Acme Inc"}`),
43+
},
44+
}
45+
46+
t.Setenv("LD_ACCESS_TOKEN", "abcd1234")
47+
48+
output, err := cmd.CallCmd(
49+
t,
50+
cmd.APIClients{ResourcesClient: mockClient},
51+
analytics.NoopClientFn{}.Tracker(),
52+
[]string{"whoami"},
53+
)
54+
55+
require.NoError(t, err)
56+
assert.Contains(t, string(output), "Ariel Flores <ariel@acme.com>")
57+
assert.Contains(t, string(output), "Role: admin")
58+
assert.Contains(t, string(output), "Token: my-token (personal)")
59+
assert.Contains(t, string(output), "Account: Acme Inc (acct1)")
60+
})
61+
62+
t.Run("falls back to account ID when organization is unavailable", func(t *testing.T) {
63+
mockClient := &sequentialMockClient{
64+
responses: [][]byte{
65+
[]byte(`{"memberId": "abc123", "tokenName": "my-token", "tokenKind": "personal", "accountId": "acct1"}`),
66+
[]byte(`{"_id": "abc123", "email": "ariel@acme.com", "firstName": "Ariel", "lastName": "Flores", "role": "admin"}`),
67+
[]byte(`{"_id": "acct1"}`),
68+
},
69+
}
70+
71+
t.Setenv("LD_ACCESS_TOKEN", "abcd1234")
72+
73+
output, err := cmd.CallCmd(
74+
t,
75+
cmd.APIClients{ResourcesClient: mockClient},
76+
analytics.NoopClientFn{}.Tracker(),
77+
[]string{"whoami"},
78+
)
79+
80+
require.NoError(t, err)
81+
assert.Contains(t, string(output), "Account: acct1")
82+
assert.NotContains(t, string(output), "Account: Acme")
83+
})
84+
85+
t.Run("without member ID shows token info only", func(t *testing.T) {
86+
mockClient := &resources.MockClient{
87+
Response: []byte(`{"tokenName": "sdk-key", "tokenKind": "server", "accountId": "acct1"}`),
88+
}
89+
90+
t.Setenv("LD_ACCESS_TOKEN", "abcd1234")
91+
92+
output, err := cmd.CallCmd(
93+
t,
94+
cmd.APIClients{ResourcesClient: mockClient},
95+
analytics.NoopClientFn{}.Tracker(),
96+
[]string{"whoami"},
97+
)
98+
99+
require.NoError(t, err)
100+
assert.Contains(t, string(output), "Token: sdk-key (server)")
101+
assert.NotContains(t, string(output), "Role:")
102+
})
103+
104+
t.Run("without configured token returns helpful error", func(t *testing.T) {
105+
_, err := cmd.CallCmd(
106+
t,
107+
cmd.APIClients{},
108+
analytics.NoopClientFn{}.Tracker(),
109+
[]string{"whoami"},
110+
)
111+
112+
require.ErrorContains(t, err, "no access token configured")
113+
})
114+
115+
t.Run("with --output json returns raw caller-identity JSON", func(t *testing.T) {
116+
mockClient := &resources.MockClient{
117+
Response: []byte(`{"tokenName": "my-token", "memberId": "abc123"}`),
118+
}
119+
120+
t.Setenv("LD_ACCESS_TOKEN", "abcd1234")
121+
122+
output, err := cmd.CallCmd(
123+
t,
124+
cmd.APIClients{ResourcesClient: mockClient},
125+
analytics.NoopClientFn{}.Tracker(),
126+
[]string{"whoami", "--output", "json"},
127+
)
128+
129+
require.NoError(t, err)
130+
assert.Contains(t, string(output), `"tokenName": "my-token"`)
131+
})
132+
}

0 commit comments

Comments
 (0)