on main help sign in
feat(oauth): add logout command (#2838)
commit303b153b975786ac1cd156203cc622bd96f27ed5 authorKieran Klukas <me@dunkirk.sh> dateTue 12 May 2026, 13:59 (5 months ago) parentcc7d651
5 files changed+276−9
internal/cmd/login.go+31−9 internal/cmd/login_test.go+21 internal/cmd/logout.go+182 internal/cmd/logout_test.go+41 internal/cmd/root.go+1
internal/cmd/login.go+31−9
@@ -32,6 +32,9 @@ crush login
3232
3333# Authenticate with GitHub Copilot
3434crush login copilot
35+
36+# Force re-authentication even if already logged in
37+crush login -f copilot
3538 `,
3639 ValidArgs: []cobra.Completion{
3740 "hyper",
@@ -57,20 +60,36 @@ crush login copilot
5760 if len(args) > 0 {
5861 provider = args[0]
5962 }
63+ force, _ := cmd.Flags().GetBool("force")
6064 switch provider {
6165 case "hyper":
62− return loginHyper(c, ws.ID)
66+ return loginHyper(c, ws.ID, force)
6367 case "copilot", "github", "github-copilot":
64− return loginCopilot(cmd.Context(), c, ws.ID)
68+ return loginCopilot(c, ws.ID, force)
6569 default:
6670 return fmt.Errorf("unknown platform: %s", args[0])
6771 }
6872 },
6973}
7074
71−func loginHyper(c *client.Client, wsID string) error {
75+func init() {
76+ loginCmd.Flags().BoolP("force", "f", false, "Force re-authentication even if already logged in")
77+}
78+
79+func loginHyper(c *client.Client, wsID string, force bool) error {
7280 ctx := getLoginContext()
7381
82+ if !force {
83+ cfg, err := c.GetConfig(ctx, wsID)
84+ if err == nil && cfg != nil {
85+ if pc, ok := cfg.Providers.Get("hyper"); ok && pc.OAuthToken != nil {
86+ fmt.Println("You are already logged in to Hyper.")
87+ fmt.Println("Use --force to re-authenticate.")
88+ return nil
89+ }
90+ }
91+ }
92+
7493 resp, err := hyper.InitiateDeviceAuth(ctx)
7594 if err != nil {
7695 return err
@@ -127,14 +146,17 @@ func loginHyper(c *client.Client, wsID string) error {
127146 return nil
128147}
129148
130−func loginCopilot(ctx context.Context, c *client.Client, wsID string) error {
149+func loginCopilot(c *client.Client, wsID string, force bool) error {
131150 loginCtx := getLoginContext()
132151
133− cfg, err := c.GetConfig(ctx, wsID)
134− if err == nil && cfg != nil {
135− if pc, ok := cfg.Providers.Get("copilot"); ok && pc.OAuthToken != nil {
136− fmt.Println("You are already logged in to GitHub Copilot.")
137− return nil
152+ if !force {
153+ cfg, err := c.GetConfig(loginCtx, wsID)
154+ if err == nil && cfg != nil {
155+ if pc, ok := cfg.Providers.Get("copilot"); ok && pc.OAuthToken != nil {
156+ fmt.Println("You are already logged in to GitHub Copilot.")
157+ fmt.Println("Use --force to re-authenticate.")
158+ return nil
159+ }
138160 }
139161 }
140162
internal/cmd/login_test.gonew file+21
@@ -0,0 +1,21 @@
1+package cmd
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestLoginCmd_Aliases(t *testing.T) {
10+ t.Parallel()
11+
12+ require.Equal(t, "auth", loginCmd.Aliases[0])
13+}
14+
15+func TestLoginCmd_ForceFlag(t *testing.T) {
16+ t.Parallel()
17+
18+ flag := loginCmd.Flags().Lookup("force")
19+ require.NotNil(t, flag)
20+ require.Equal(t, "f", flag.Shorthand)
21+}
internal/cmd/logout.gonew file+182
@@ -0,0 +1,182 @@
1+package cmd
2+
3+import (
4+ "cmp"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/signal"
9+
10+ "charm.land/lipgloss/v2"
11+ "github.com/charmbracelet/crush/internal/client"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/x/ansi"
14+ "github.com/spf13/cobra"
15+)
16+
17+var (
18+ logoutHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
19+ logoutItemStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
20+ logoutPromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("215"))
21+)
22+
23+var logoutCmd = &cobra.Command{
24+ Aliases: []string{"signout"},
25+ Use: "logout [platform]",
26+ Short: "Logout Crush from a platform",
27+ Long: `Logout Crush from a specified platform, removing stored credentials.
28+The platform should be provided as an argument.
29+If no argument is given, a list of logged-in platforms will be shown.
30+Available platforms are: hyper, copilot.`,
31+ Example: `
32+# Sign out from Charm Hyper
33+crush logout hyper
34+
35+# Sign out from GitHub Copilot
36+crush logout copilot
37+ `,
38+ ValidArgs: []cobra.Completion{
39+ "hyper",
40+ "copilot",
41+ "github",
42+ "github-copilot",
43+ },
44+ Args: cobra.MaximumNArgs(1),
45+ RunE: func(cmd *cobra.Command, args []string) error {
46+ c, ws, cleanup, err := connectToServer(cmd)
47+ if err != nil {
48+ return err
49+ }
50+ defer cleanup()
51+
52+ progressEnabled := ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
53+ if progressEnabled && supportsProgressBar() {
54+ _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
55+ defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
56+ }
57+
58+ var provider string
59+ if len(args) == 0 {
60+ provider, err = pickLoggedInProvider(c, ws.ID)
61+ if err != nil {
62+ return err
63+ }
64+ if provider == "" {
65+ return nil
66+ }
67+ } else {
68+ provider = args[0]
69+ }
70+
71+ force, _ := cmd.Flags().GetBool("force")
72+ if !force {
73+ fmt.Print(logoutPromptStyle.Render(fmt.Sprintf("Are you sure you want to logout %s? (y/N) ", provider)))
74+ var response string
75+ _, err := fmt.Scanln(&response)
76+ if err != nil || (response != "y" && response != "Y" && response != "yes" && response != "Yes" && response != "YES") {
77+ fmt.Println(logoutHeaderStyle.Render("Logout cancelled."))
78+ return nil
79+ }
80+ }
81+
82+ switch provider {
83+ case "hyper":
84+ return logoutHyper(c, ws.ID)
85+ case "copilot", "github", "github-copilot":
86+ return logoutCopilot(c, ws.ID)
87+ default:
88+ return fmt.Errorf("unknown platform: %s", provider)
89+ }
90+ },
91+}
92+
93+func logoutHyper(c *client.Client, wsID string) error {
94+ ctx := getLogoutContext()
95+
96+ if err := cmp.Or(
97+ c.RemoveConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.api_key"),
98+ c.RemoveConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.oauth"),
99+ ); err != nil {
100+ return err
101+ }
102+
103+ fmt.Println(logoutHeaderStyle.Render("Successfully logged out of Hyper."))
104+ return nil
105+}
106+
107+func logoutCopilot(c *client.Client, wsID string) error {
108+ ctx := getLogoutContext()
109+
110+ if err := cmp.Or(
111+ c.RemoveConfigField(ctx, wsID, config.ScopeGlobal, "providers.copilot.api_key"),
112+ c.RemoveConfigField(ctx, wsID, config.ScopeGlobal, "providers.copilot.oauth"),
113+ ); err != nil {
114+ return err
115+ }
116+
117+ fmt.Println(logoutHeaderStyle.Render("Successfully logged out of GitHub Copilot."))
118+ return nil
119+}
120+
121+func pickLoggedInProvider(c *client.Client, wsID string) (string, error) {
122+ ctx := getLogoutContext()
123+
124+ cfg, err := c.GetConfig(ctx, wsID)
125+ if err != nil {
126+ return "", fmt.Errorf("failed to get config: %w", err)
127+ }
128+
129+ type loggedInProvider struct {
130+ id string
131+ name string
132+ }
133+
134+ var loggedIn []loggedInProvider
135+ for p := range cfg.Providers.Seq() {
136+ if p.OAuthToken != nil || p.APIKey != "" {
137+ name := p.Name
138+ if name == "" {
139+ name = p.ID
140+ }
141+ loggedIn = append(loggedIn, loggedInProvider{id: p.ID, name: name})
142+ }
143+ }
144+
145+ if len(loggedIn) == 0 {
146+ fmt.Println(logoutPromptStyle.Render("You are not logged in to any platform."))
147+ return "", nil
148+ }
149+
150+ if len(loggedIn) == 1 {
151+ return loggedIn[0].id, nil
152+ }
153+
154+ fmt.Println(logoutHeaderStyle.Render("Logged-in platforms:"))
155+ for i, p := range loggedIn {
156+ fmt.Println(logoutItemStyle.Render(fmt.Sprintf(" %d. %s", i+1, p.name)))
157+ }
158+ fmt.Print(logoutPromptStyle.Render(fmt.Sprintf("Select a platform to logout (1-%d): ", len(loggedIn))))
159+
160+ var choice int
161+ _, err = fmt.Scanln(&choice)
162+ if err != nil || choice < 1 || choice > len(loggedIn) {
163+ fmt.Println(logoutHeaderStyle.Render("Logout cancelled."))
164+ return "", nil
165+ }
166+
167+ return loggedIn[choice-1].id, nil
168+}
169+
170+func init() {
171+ logoutCmd.Flags().BoolP("force", "f", false, "Skip logout confirmation prompt")
172+}
173+
174+func getLogoutContext() context.Context {
175+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
176+ go func() {
177+ <-ctx.Done()
178+ cancel()
179+ os.Exit(1)
180+ }()
181+ return ctx
182+}
internal/cmd/logout_test.gonew file+41
@@ -0,0 +1,41 @@
1+package cmd
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestLogoutCmd_Aliases(t *testing.T) {
10+ t.Parallel()
11+
12+ require.Equal(t, "signout", logoutCmd.Aliases[0])
13+}
14+
15+func TestLogoutCmd_HasForceFlag(t *testing.T) {
16+ t.Parallel()
17+
18+ flag := logoutCmd.Flags().Lookup("force")
19+ require.NotNil(t, flag)
20+ require.Equal(t, "f", flag.Shorthand)
21+ require.Equal(t, "false", flag.DefValue)
22+}
23+
24+func TestLogoutCmd_ValidArgs(t *testing.T) {
25+ t.Parallel()
26+
27+ validPlatforms := map[string]bool{}
28+ for _, p := range logoutCmd.ValidArgs {
29+ validPlatforms[p] = true
30+ }
31+ require.True(t, validPlatforms["hyper"])
32+ require.True(t, validPlatforms["copilot"])
33+ require.True(t, validPlatforms["github"])
34+ require.True(t, validPlatforms["github-copilot"])
35+}
36+
37+func TestLogoutContext_CreatesValidContext(t *testing.T) {
38+ ctx := getLogoutContext()
39+ require.NotNil(t, ctx)
40+ require.NoError(t, ctx.Err())
41+}
internal/cmd/root.go+1
@@ -63,6 +63,7 @@ func init() {
6363 projectsCmd,
6464 updateProvidersCmd,
6565 logsCmd,
66+ logoutCmd,
6667 schemaCmd,
6768 loginCmd,
6869 statsCmd,