From 957bd81fa54b5183b978de774f19e18e934358dd Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 4.5" Date: Tue, 10 Mar 2026 10:02:20 -0700 Subject: [PATCH 1/2] add /status slash command for session and auth info Shows Claude CLI version, active session details (name, ID, cwd), and authentication status (login method, organization, email) by running `claude --version` and `claude auth status`. Co-Authored-By: Claude Opus 4.6 --- internal/app/slash_commands.go | 121 +++++++++++++++ internal/app/slash_commands_test.go | 221 +++++++++++++++++++++++++++- 2 files changed, 340 insertions(+), 2 deletions(-) diff --git a/internal/app/slash_commands.go b/internal/app/slash_commands.go index a41c2452..555b2b5e 100644 --- a/internal/app/slash_commands.go +++ b/internal/app/slash_commands.go @@ -1,11 +1,14 @@ package app import ( + "context" "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" + "time" "github.com/zhubert/plural/internal/logger" ) @@ -52,6 +55,10 @@ func getSlashCommands() []slashCommandDef { name: "plugins", description: "Manage plugin directories", }, + { + name: "status", + description: "Show session and Claude CLI status", + }, } } @@ -82,6 +89,8 @@ func (m *Model) handleSlashCommand(input string) SlashCommandResult { return handleMCPCommand(m, args) case "plugin", "plugins": return handlePluginsCommand(m, args) + case "status": + return handleStatusCommand(m, args) default: // Unknown slash command - let Claude handle it (might be a custom command) logger.Get().Debug("unknown slash command, passing to Claude", "command", cmdName) @@ -311,6 +320,118 @@ func getSessionUsageStats(sessionID string, workingDir string) (*UsageStats, err return stats, nil } +// claudeAuthStatus represents the JSON output of `claude auth status`. +type claudeAuthStatus struct { + LoggedIn bool `json:"loggedIn"` + AuthMethod string `json:"authMethod"` + APIProvider string `json:"apiProvider"` + Email string `json:"email"` + OrgID string `json:"orgId"` + OrgName *string `json:"orgName"` + SubscriptionType string `json:"subscriptionType"` +} + +// loginMethodDisplay returns a human-readable login method string. +func (a *claudeAuthStatus) loginMethodDisplay() string { + switch a.SubscriptionType { + case "max": + return "Claude Max Account" + case "pro": + return "Claude Pro Account" + case "team": + return "Claude Team Account" + case "enterprise": + return "Claude Enterprise Account" + default: + if a.AuthMethod == "api-key" || a.AuthMethod == "apiKey" { + return "API Key" + } + if a.AuthMethod != "" { + return a.AuthMethod + } + return "Unknown" + } +} + +// runClaudeAuthStatus executes `claude auth status` and parses the JSON output. +// Extracted as a variable for testability. +var runClaudeAuthStatus = func() (*claudeAuthStatus, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "claude", "auth", "status") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to run claude auth status: %w", err) + } + + var status claudeAuthStatus + if err := json.Unmarshal(output, &status); err != nil { + return nil, fmt.Errorf("failed to parse auth status: %w", err) + } + return &status, nil +} + +// runClaudeVersion executes `claude --version` and returns the version string. +// Extracted as a variable for testability. +var runClaudeVersion = func() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "claude", "--version") + output, err := cmd.Output() + if err != nil { + return "unknown" + } + return strings.TrimSpace(string(output)) +} + +// handleStatusCommand shows session and Claude CLI status information. +func handleStatusCommand(m *Model, _ string) SlashCommandResult { + var sb strings.Builder + sb.WriteString("**Status**\n\n") + + // Claude CLI version + version := runClaudeVersion() + fmt.Fprintf(&sb, " Version: %s\n", version) + + // Session info + if m.activeSession != nil { + sess := m.activeSession + name := sess.Name + if name == "" { + name = "(unnamed)" + } + fmt.Fprintf(&sb, " Session name: %s\n", name) + fmt.Fprintf(&sb, " Session ID: %s\n", sess.ID) + fmt.Fprintf(&sb, " cwd: %s\n", sess.WorkTree) + } else { + sb.WriteString(" Session: none\n") + } + + // Auth status + authStatus, err := runClaudeAuthStatus() + if err != nil { + logger.Get().Debug("failed to get claude auth status", "error", err) + sb.WriteString(" Login: could not determine\n") + } else if !authStatus.LoggedIn { + sb.WriteString(" Login: not logged in\n") + } else { + fmt.Fprintf(&sb, " Login method: %s\n", authStatus.loginMethodDisplay()) + if authStatus.OrgName != nil && *authStatus.OrgName != "" { + fmt.Fprintf(&sb, " Organization: %s\n", *authStatus.OrgName) + } + if authStatus.Email != "" { + fmt.Fprintf(&sb, " Email: %s\n", authStatus.Email) + } + } + + return SlashCommandResult{ + Handled: true, + Response: sb.String(), + } +} + // formatNumber formats a number with thousand separators. func formatNumber(n int64) string { str := fmt.Sprintf("%d", n) diff --git a/internal/app/slash_commands_test.go b/internal/app/slash_commands_test.go index 8da2131d..b0a4eb21 100644 --- a/internal/app/slash_commands_test.go +++ b/internal/app/slash_commands_test.go @@ -2,8 +2,11 @@ package app import ( "encoding/json" + "fmt" "strings" "testing" + + "github.com/zhubert/plural/internal/config" ) func TestFormatNumber(t *testing.T) { @@ -44,7 +47,7 @@ func TestHandleHelpCommand(t *testing.T) { } // Check that the response contains expected commands - expected := []string{"/cost", "/help", "/mcp", "Plural Slash Commands"} + expected := []string{"/cost", "/help", "/mcp", "/status", "Plural Slash Commands"} for _, exp := range expected { if !strings.Contains(result.Response, exp) { t.Errorf("handleHelpCommand response should contain %q", exp) @@ -84,7 +87,7 @@ func TestGetSlashCommands(t *testing.T) { } // Check that required commands exist - expectedCommands := []string{"cost", "help", "mcp", "plugins"} + expectedCommands := []string{"cost", "help", "mcp", "plugins", "status"} for _, expected := range expectedCommands { found := false for _, cmd := range commands { @@ -262,6 +265,202 @@ func TestSessionJSONLEntry_WithCache(t *testing.T) { } } +// ============================================================================= +// /status Command Tests +// ============================================================================= + +func TestHandleStatusCommand_NoSession(t *testing.T) { + // Override the CLI calls for testing + origVersion := runClaudeVersion + origAuth := runClaudeAuthStatus + defer func() { + runClaudeVersion = origVersion + runClaudeAuthStatus = origAuth + }() + + runClaudeVersion = func() string { return "2.1.72 (Claude Code)" } + runClaudeAuthStatus = func() (*claudeAuthStatus, error) { + orgName := "My Org" + return &claudeAuthStatus{ + LoggedIn: true, + AuthMethod: "claude.ai", + Email: "test@example.com", + OrgName: &orgName, + SubscriptionType: "max", + }, nil + } + + m := &Model{activeSession: nil} + result := handleStatusCommand(m, "") + + if !result.Handled { + t.Error("handleStatusCommand should return Handled=true") + } + + // Should show version + if !strings.Contains(result.Response, "2.1.72") { + t.Error("Response should contain version") + } + + // Should show no session + if !strings.Contains(result.Response, "Session: none") { + t.Error("Response should indicate no active session") + } + + // Should show auth info + if !strings.Contains(result.Response, "Claude Max Account") { + t.Errorf("Response should contain login method, got: %s", result.Response) + } + if !strings.Contains(result.Response, "test@example.com") { + t.Error("Response should contain email") + } + if !strings.Contains(result.Response, "My Org") { + t.Error("Response should contain organization") + } +} + +func TestHandleStatusCommand_WithSession(t *testing.T) { + origVersion := runClaudeVersion + origAuth := runClaudeAuthStatus + defer func() { + runClaudeVersion = origVersion + runClaudeAuthStatus = origAuth + }() + + runClaudeVersion = func() string { return "2.1.72" } + runClaudeAuthStatus = func() (*claudeAuthStatus, error) { + return &claudeAuthStatus{ + LoggedIn: true, + AuthMethod: "claude.ai", + Email: "user@test.com", + SubscriptionType: "pro", + }, nil + } + + m := &Model{ + activeSession: &config.Session{ + ID: "abc-123-def", + Name: "my-feature", + WorkTree: "/home/user/project", + }, + } + result := handleStatusCommand(m, "") + + if !result.Handled { + t.Error("handleStatusCommand should return Handled=true") + } + if !strings.Contains(result.Response, "my-feature") { + t.Error("Response should contain session name") + } + if !strings.Contains(result.Response, "abc-123-def") { + t.Error("Response should contain session ID") + } + if !strings.Contains(result.Response, "/home/user/project") { + t.Error("Response should contain cwd") + } + if !strings.Contains(result.Response, "Claude Pro Account") { + t.Errorf("Response should contain login method, got: %s", result.Response) + } +} + +func TestHandleStatusCommand_AuthError(t *testing.T) { + origVersion := runClaudeVersion + origAuth := runClaudeAuthStatus + defer func() { + runClaudeVersion = origVersion + runClaudeAuthStatus = origAuth + }() + + runClaudeVersion = func() string { return "2.1.72" } + runClaudeAuthStatus = func() (*claudeAuthStatus, error) { + return nil, fmt.Errorf("command not found") + } + + m := &Model{activeSession: nil} + result := handleStatusCommand(m, "") + + if !result.Handled { + t.Error("handleStatusCommand should return Handled=true") + } + if !strings.Contains(result.Response, "could not determine") { + t.Error("Response should indicate auth failure") + } +} + +func TestHandleStatusCommand_NotLoggedIn(t *testing.T) { + origVersion := runClaudeVersion + origAuth := runClaudeAuthStatus + defer func() { + runClaudeVersion = origVersion + runClaudeAuthStatus = origAuth + }() + + runClaudeVersion = func() string { return "2.1.72" } + runClaudeAuthStatus = func() (*claudeAuthStatus, error) { + return &claudeAuthStatus{LoggedIn: false}, nil + } + + m := &Model{activeSession: nil} + result := handleStatusCommand(m, "") + + if !strings.Contains(result.Response, "not logged in") { + t.Error("Response should indicate not logged in") + } +} + +func TestHandleStatusCommand_UnnamedSession(t *testing.T) { + origVersion := runClaudeVersion + origAuth := runClaudeAuthStatus + defer func() { + runClaudeVersion = origVersion + runClaudeAuthStatus = origAuth + }() + + runClaudeVersion = func() string { return "2.1.72" } + runClaudeAuthStatus = func() (*claudeAuthStatus, error) { + return &claudeAuthStatus{LoggedIn: true, SubscriptionType: "max"}, nil + } + + m := &Model{ + activeSession: &config.Session{ + ID: "test-id", + Name: "", + WorkTree: "/tmp/work", + }, + } + result := handleStatusCommand(m, "") + + if !strings.Contains(result.Response, "(unnamed)") { + t.Error("Response should show (unnamed) for sessions without a name") + } +} + +func TestClaudeAuthStatus_LoginMethodDisplay(t *testing.T) { + tests := []struct { + name string + status claudeAuthStatus + expected string + }{ + {"max subscription", claudeAuthStatus{SubscriptionType: "max"}, "Claude Max Account"}, + {"pro subscription", claudeAuthStatus{SubscriptionType: "pro"}, "Claude Pro Account"}, + {"team subscription", claudeAuthStatus{SubscriptionType: "team"}, "Claude Team Account"}, + {"enterprise subscription", claudeAuthStatus{SubscriptionType: "enterprise"}, "Claude Enterprise Account"}, + {"api key auth", claudeAuthStatus{AuthMethod: "api-key"}, "API Key"}, + {"apiKey auth", claudeAuthStatus{AuthMethod: "apiKey"}, "API Key"}, + {"other auth method", claudeAuthStatus{AuthMethod: "oauth"}, "oauth"}, + {"unknown", claudeAuthStatus{}, "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.status.loginMethodDisplay() + if result != tt.expected { + t.Errorf("loginMethodDisplay() = %q, want %q", result, tt.expected) + } + }) + } +} + func TestSlashCommandDef(t *testing.T) { cmd := slashCommandDef{ name: "test", @@ -282,6 +481,18 @@ func TestSlashCommandDef(t *testing.T) { // ============================================================================= func TestHandleSlashCommand_Dispatcher(t *testing.T) { + // Mock CLI calls for /status command + origVersion := runClaudeVersion + origAuth := runClaudeAuthStatus + defer func() { + runClaudeVersion = origVersion + runClaudeAuthStatus = origAuth + }() + runClaudeVersion = func() string { return "2.1.72" } + runClaudeAuthStatus = func() (*claudeAuthStatus, error) { + return &claudeAuthStatus{LoggedIn: true, SubscriptionType: "max"}, nil + } + cfg := testConfigWithSessions() m := testModelWithSize(cfg, 120, 40) m.sidebar.SetSessions(cfg.Sessions) @@ -328,6 +539,12 @@ func TestHandleSlashCommand_Dispatcher(t *testing.T) { wantHandled: true, wantAction: ActionOpenPlugins, }, + { + name: "status returns info", + input: "/status", + wantHandled: true, + wantResponse: "Status", + }, { name: "unknown command is not handled", input: "/foobar", From bc76bd74c8741a4f175e399f270027e3dc08d86e Mon Sep 17 00:00:00 2001 From: "Claude Sonnet 4.5" Date: Tue, 10 Mar 2026 10:10:51 -0700 Subject: [PATCH 2/2] add --output-format json flag to claude auth status call Ensures JSON output regardless of TTY detection, addressing PR review feedback. Co-Authored-By: Claude Opus 4.6 --- internal/app/slash_commands.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/slash_commands.go b/internal/app/slash_commands.go index 555b2b5e..3d2cb917 100644 --- a/internal/app/slash_commands.go +++ b/internal/app/slash_commands.go @@ -359,7 +359,7 @@ var runClaudeAuthStatus = func() (*claudeAuthStatus, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "claude", "auth", "status") + cmd := exec.CommandContext(ctx, "claude", "auth", "status", "--output-format", "json") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to run claude auth status: %w", err)