Merge branch 'main' into ui

Andrey Nering created

Change summary

go.mod                                           |   2 
go.sum                                           |   4 
internal/agent/agent.go                          |  18 -
internal/agent/coordinator.go                    |  14 
internal/agent/tools/edit.go                     |  21 
internal/agent/tools/file.go                     |  53 ---
internal/agent/tools/mcp/init.go                 |   4 
internal/agent/tools/mcp/tools.go                |  29 +
internal/agent/tools/multiedit.go                |  13 
internal/agent/tools/multiedit_test.go           |   3 
internal/agent/tools/view.go                     |   3 
internal/agent/tools/write.go                    |   7 
internal/cmd/login.go                            |  65 ----
internal/cmd/run.go                              |   2 
internal/config/config.go                        |  41 +-
internal/config/load.go                          |  47 +-
internal/event/event.go                          |   8 
internal/filetracker/filetracker.go              |  70 ++++
internal/oauth/claude/challenge.go               |  28 -
internal/oauth/claude/oauth.go                   | 126 --------
internal/tui/components/chat/editor/editor.go    |  11 
internal/tui/components/chat/splash/splash.go    | 202 -------------
internal/tui/components/dialogs/claude/method.go | 115 -------
internal/tui/components/dialogs/claude/oauth.go  | 267 ------------------
internal/tui/components/dialogs/models/keys.go   |  57 ---
internal/tui/components/dialogs/models/models.go | 122 --------
internal/tui/page/chat/chat.go                   |  52 ---
27 files changed, 198 insertions(+), 1,186 deletions(-)

Detailed changes

go.mod 🔗

@@ -18,7 +18,7 @@ require (
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.9.1
 	github.com/charlievieth/fastwalk v1.0.14
-	github.com/charmbracelet/catwalk v0.12.0
+	github.com/charmbracelet/catwalk v0.12.2
 	github.com/charmbracelet/colorprofile v0.4.1
 	github.com/charmbracelet/fang v0.4.4
 	github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560

go.sum 🔗

@@ -94,8 +94,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg
 github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY=
 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw=
 github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4=
-github.com/charmbracelet/catwalk v0.12.0 h1:CCxbZpgMPyZNtnaRGvL//BgPkvOWOYVFhRf925Dfrdg=
-github.com/charmbracelet/catwalk v0.12.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
+github.com/charmbracelet/catwalk v0.12.2 h1:zq9b+7kiumof/Dzvqi/oHnwMBgSN/M2Yt82vlIAiKMU=
+github.com/charmbracelet/catwalk v0.12.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ=
 github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
 github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
 github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=

internal/agent/agent.go 🔗

@@ -833,10 +833,6 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 		modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
 		modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
 
-	if a.isClaudeCode() {
-		cost = 0
-	}
-
 	// Use override cost if available (e.g., from OpenRouter).
 	if openrouterCost != nil {
 		cost = *openrouterCost
@@ -874,10 +870,6 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session,
 		modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
 		modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
 
-	if a.isClaudeCode() {
-		cost = 0
-	}
-
 	a.eventTokensUsed(session.ID, model, usage, cost)
 
 	if overrideCost != nil {
@@ -985,19 +977,9 @@ func (a *sessionAgent) Model() Model {
 }
 
 func (a *sessionAgent) promptPrefix() string {
-	if a.isClaudeCode() {
-		return "You are Claude Code, Anthropic's official CLI for Claude."
-	}
 	return a.systemPromptPrefix
 }
 
-// XXX: this should be generalized to cover other subscription plans, like Copilot.
-func (a *sessionAgent) isClaudeCode() bool {
-	cfg := config.Get()
-	pc, ok := cfg.Providers.Get(a.largeModel.ModelCfg.Provider)
-	return ok && pc.ID == string(catwalk.InferenceProviderAnthropic) && pc.OAuthToken != nil
-}
-
 // convertToToolResult converts a fantasy tool result to a message tool result.
 func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) message.ToolResult {
 	baseResult := message.ToolResult{

internal/agent/coordinator.go 🔗

@@ -407,12 +407,6 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 	}
 
 	for _, tool := range tools.GetMCPTools(c.permissions, c.cfg.WorkingDir()) {
-		// Check MCP-specific disabled tools.
-		if mcpCfg, ok := c.cfg.MCP[tool.MCP()]; ok {
-			if slices.Contains(mcpCfg.DisabledTools, tool.MCPToolName()) {
-				continue
-			}
-		}
 		if agent.AllowedMCP == nil {
 			// No MCP restrictions
 			filteredTools = append(filteredTools, tool)
@@ -524,13 +518,13 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo
 		}, nil
 }
 
-func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, isOauth bool) (fantasy.Provider, error) {
+func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
 	var opts []anthropic.Option
 
-	if isOauth {
+	if strings.HasPrefix(apiKey, "Bearer ") {
 		// NOTE: Prevent the SDK from picking up the API key from env.
 		os.Setenv("ANTHROPIC_API_KEY", "")
-		headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
+		headers["Authorization"] = apiKey
 	} else if apiKey != "" {
 		// X-Api-Key header
 		opts = append(opts, anthropic.WithAPIKey(apiKey))
@@ -737,7 +731,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
 	case openai.Name:
 		return c.buildOpenaiProvider(baseURL, apiKey, headers)
 	case anthropic.Name:
-		return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.OAuthToken != nil)
+		return c.buildAnthropicProvider(baseURL, apiKey, headers)
 	case openrouter.Name:
 		return c.buildOpenrouterProvider(baseURL, apiKey, headers)
 	case azure.Name:

internal/agent/tools/edit.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
 
@@ -159,8 +160,8 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
+	filetracker.RecordWrite(filePath)
+	filetracker.RecordRead(filePath)
 
 	return fantasy.WithResponseMetadata(
 		fantasy.NewTextResponse("File created: "+filePath),
@@ -186,12 +187,12 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
 	}
 
-	if getLastReadTime(filePath).IsZero() {
+	if filetracker.LastReadTime(filePath).IsZero() {
 		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
 	}
 
 	modTime := fileInfo.ModTime()
-	lastRead := getLastReadTime(filePath)
+	lastRead := filetracker.LastReadTime(filePath)
 	if modTime.After(lastRead) {
 		return fantasy.NewTextErrorResponse(
 			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -292,8 +293,8 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
+	filetracker.RecordWrite(filePath)
+	filetracker.RecordRead(filePath)
 
 	return fantasy.WithResponseMetadata(
 		fantasy.NewTextResponse("Content deleted from file: "+filePath),
@@ -319,12 +320,12 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
 	}
 
-	if getLastReadTime(filePath).IsZero() {
+	if filetracker.LastReadTime(filePath).IsZero() {
 		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
 	}
 
 	modTime := fileInfo.ModTime()
-	lastRead := getLastReadTime(filePath)
+	lastRead := filetracker.LastReadTime(filePath)
 	if modTime.After(lastRead) {
 		return fantasy.NewTextErrorResponse(
 			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -427,8 +428,8 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	recordFileWrite(filePath)
-	recordFileRead(filePath)
+	filetracker.RecordWrite(filePath)
+	filetracker.RecordRead(filePath)
 
 	return fantasy.WithResponseMetadata(
 		fantasy.NewTextResponse("Content replaced in file: "+filePath),

internal/agent/tools/file.go 🔗

@@ -1,53 +0,0 @@
-package tools
-
-import (
-	"sync"
-	"time"
-)
-
-// File record to track when files were read/written
-type fileRecord struct {
-	path      string
-	readTime  time.Time
-	writeTime time.Time
-}
-
-var (
-	fileRecords     = make(map[string]fileRecord)
-	fileRecordMutex sync.RWMutex
-)
-
-func recordFileRead(path string) {
-	fileRecordMutex.Lock()
-	defer fileRecordMutex.Unlock()
-
-	record, exists := fileRecords[path]
-	if !exists {
-		record = fileRecord{path: path}
-	}
-	record.readTime = time.Now()
-	fileRecords[path] = record
-}
-
-func getLastReadTime(path string) time.Time {
-	fileRecordMutex.RLock()
-	defer fileRecordMutex.RUnlock()
-
-	record, exists := fileRecords[path]
-	if !exists {
-		return time.Time{}
-	}
-	return record.readTime
-}
-
-func recordFileWrite(path string) {
-	fileRecordMutex.Lock()
-	defer fileRecordMutex.Unlock()
-
-	record, exists := fileRecords[path]
-	if !exists {
-		record = fileRecord{path: path}
-	}
-	record.writeTime = time.Now()
-	fileRecords[path] = record
-}

internal/agent/tools/mcp/init.go 🔗

@@ -188,12 +188,12 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 				return
 			}
 
-			updateTools(name, tools)
+			toolCount := updateTools(name, tools)
 			updatePrompts(name, prompts)
 			sessions.Set(name, session)
 
 			updateState(name, StateConnected, nil, session, Counts{
-				Tools:   len(tools),
+				Tools:   toolCount,
 				Prompts: len(prompts),
 			})
 		}(name, m)

internal/agent/tools/mcp/tools.go 🔗

@@ -6,8 +6,10 @@ import (
 	"fmt"
 	"iter"
 	"log/slog"
+	"slices"
 	"strings"
 
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
@@ -119,10 +121,10 @@ func RefreshTools(ctx context.Context, name string) {
 		return
 	}
 
-	updateTools(name, tools)
+	toolCount := updateTools(name, tools)
 
 	prev, _ := states.Get(name)
-	prev.Counts.Tools = len(tools)
+	prev.Counts.Tools = toolCount
 	updateState(name, StateConnected, nil, session, prev.Counts)
 }
 
@@ -137,10 +139,29 @@ func getTools(ctx context.Context, session *mcp.ClientSession) ([]*Tool, error)
 	return result.Tools, nil
 }
 
-func updateTools(name string, tools []*Tool) {
+func updateTools(name string, tools []*Tool) int {
+	tools = filterDisabledTools(name, tools)
 	if len(tools) == 0 {
 		allTools.Del(name)
-		return
+		return 0
 	}
 	allTools.Set(name, tools)
+	return len(tools)
+}
+
+// filterDisabledTools removes tools that are disabled via config.
+func filterDisabledTools(mcpName string, tools []*Tool) []*Tool {
+	cfg := config.Get()
+	mcpCfg, ok := cfg.MCP[mcpName]
+	if !ok || len(mcpCfg.DisabledTools) == 0 {
+		return tools
+	}
+
+	filtered := make([]*Tool, 0, len(tools))
+	for _, tool := range tools {
+		if !slices.Contains(mcpCfg.DisabledTools, tool.Name) {
+			filtered = append(filtered, tool)
+		}
+	}
+	return filtered
 }

internal/agent/tools/multiedit.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/lsp"
@@ -206,8 +207,8 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	recordFileWrite(params.FilePath)
-	recordFileRead(params.FilePath)
+	filetracker.RecordWrite(params.FilePath)
+	filetracker.RecordRead(params.FilePath)
 
 	var message string
 	if len(failedEdits) > 0 {
@@ -244,13 +245,13 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 	}
 
 	// Check if file was read before editing
-	if getLastReadTime(params.FilePath).IsZero() {
+	if filetracker.LastReadTime(params.FilePath).IsZero() {
 		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
 	}
 
 	// Check if file was modified since last read
 	modTime := fileInfo.ModTime()
-	lastRead := getLastReadTime(params.FilePath)
+	lastRead := filetracker.LastReadTime(params.FilePath)
 	if modTime.After(lastRead) {
 		return fantasy.NewTextErrorResponse(
 			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
@@ -362,8 +363,8 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
 		slog.Error("Error creating file history version", "error", err)
 	}
 
-	recordFileWrite(params.FilePath)
-	recordFileRead(params.FilePath)
+	filetracker.RecordWrite(params.FilePath)
+	filetracker.RecordRead(params.FilePath)
 
 	var message string
 	if len(failedEdits) > 0 {

internal/agent/tools/multiedit_test.go 🔗

@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/permission"
@@ -119,7 +120,7 @@ func TestMultiEditSequentialApplication(t *testing.T) {
 	_ = NewMultiEditTool(lspClients, permissions, files, tmpDir)
 
 	// Simulate reading the file first.
-	recordFileRead(testFile)
+	filetracker.RecordRead(testFile)
 
 	// Manually test the sequential application logic.
 	currentContent := content

internal/agent/tools/view.go 🔗

@@ -15,6 +15,7 @@ import (
 	"charm.land/fantasy"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/permission"
 )
@@ -194,7 +195,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
 			}
 			output += "\n</file>\n"
 			output += getDiagnostics(filePath, lspClients)
-			recordFileRead(filePath)
+			filetracker.RecordRead(filePath)
 			return fantasy.WithResponseMetadata(
 				fantasy.NewTextResponse(output),
 				ViewResponseMetadata{

internal/agent/tools/write.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
 
@@ -72,7 +73,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
 				}
 
 				modTime := fileInfo.ModTime()
-				lastRead := getLastReadTime(filePath)
+				lastRead := filetracker.LastReadTime(filePath)
 				if modTime.After(lastRead) {
 					return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
 						filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
@@ -156,8 +157,8 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
 				slog.Error("Error creating file history version", "error", err)
 			}
 
-			recordFileWrite(filePath)
-			recordFileRead(filePath)
+			filetracker.RecordWrite(filePath)
+			filetracker.RecordRead(filePath)
 
 			notifyLSPs(ctx, lspClients, params.FilePath)
 

internal/cmd/login.go 🔗

@@ -6,14 +6,12 @@ import (
 	"fmt"
 	"os"
 	"os/signal"
-	"strings"
 
 	"charm.land/lipgloss/v2"
 	"github.com/atotto/clipboard"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/oauth"
-	"github.com/charmbracelet/crush/internal/oauth/claude"
 	"github.com/charmbracelet/crush/internal/oauth/copilot"
 	"github.com/charmbracelet/crush/internal/oauth/hyper"
 	"github.com/pkg/browser"
@@ -26,21 +24,16 @@ var loginCmd = &cobra.Command{
 	Short:   "Login Crush to a platform",
 	Long: `Login Crush to a specified platform.
 The platform should be provided as an argument.
-Available platforms are: hyper, claude, copilot.`,
+Available platforms are: hyper, copilot.`,
 	Example: `
 # Authenticate with Charm Hyper
 crush login
 
-# Authenticate with Claude Code Max
-crush login claude
-
 # Authenticate with GitHub Copilot
 crush login copilot
   `,
 	ValidArgs: []cobra.Completion{
 		"hyper",
-		"claude",
-		"anthropic",
 		"copilot",
 		"github",
 		"github-copilot",
@@ -60,8 +53,6 @@ crush login copilot
 		switch provider {
 		case "hyper":
 			return loginHyper()
-		case "anthropic", "claude":
-			return loginClaude()
 		case "copilot", "github", "github-copilot":
 			return loginCopilot()
 		default:
@@ -133,60 +124,6 @@ func loginHyper() error {
 	return nil
 }
 
-func loginClaude() error {
-	ctx := getLoginContext()
-
-	cfg := config.Get()
-	if cfg.HasConfigField("providers.anthropic.oauth") {
-		fmt.Println("You are already logged in to Claude.")
-		return nil
-	}
-
-	verifier, challenge, err := claude.GetChallenge()
-	if err != nil {
-		return err
-	}
-	url, err := claude.AuthorizeURL(verifier, challenge)
-	if err != nil {
-		return err
-	}
-	fmt.Println("Open the following URL and follow the instructions to authenticate with Claude Code Max:")
-	fmt.Println()
-	fmt.Println(lipgloss.NewStyle().Hyperlink(url, "id=claude").Render(url))
-	fmt.Println()
-	fmt.Println("Press enter to continue...")
-	if _, err := fmt.Scanln(); err != nil {
-		return err
-	}
-
-	fmt.Println("Now paste and code from Anthropic and press enter...")
-	fmt.Println()
-	fmt.Print("> ")
-	var code string
-	for code == "" {
-		_, _ = fmt.Scanln(&code)
-		code = strings.TrimSpace(code)
-	}
-
-	fmt.Println()
-	fmt.Println("Exchanging authorization code...")
-	token, err := claude.ExchangeToken(ctx, code, verifier)
-	if err != nil {
-		return err
-	}
-
-	if err := cmp.Or(
-		cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken),
-		cfg.SetConfigField("providers.anthropic.oauth", token),
-	); err != nil {
-		return err
-	}
-
-	fmt.Println()
-	fmt.Println("You're now authenticated with Claude Code Max!")
-	return nil
-}
-
 func loginCopilot() error {
 	ctx := getLoginContext()
 

internal/cmd/run.go 🔗

@@ -59,7 +59,7 @@ crush run --quiet "Generate a README for this project"
 			return fmt.Errorf("no prompt provided")
 		}
 
-		event.SetInteractive(true)
+		event.SetNonInteractive(true)
 		event.AppInitialized()
 
 		return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)

internal/config/config.go 🔗

@@ -19,7 +19,6 @@ import (
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
 	"github.com/charmbracelet/crush/internal/oauth"
-	"github.com/charmbracelet/crush/internal/oauth/claude"
 	"github.com/charmbracelet/crush/internal/oauth/copilot"
 	"github.com/charmbracelet/crush/internal/oauth/hyper"
 	"github.com/invopop/jsonschema"
@@ -160,21 +159,6 @@ func (pc *ProviderConfig) ToProvider() catwalk.Provider {
 	return provider
 }
 
-func (pc *ProviderConfig) SetupClaudeCode() {
-	pc.SystemPromptPrefix = "You are Claude Code, Anthropic's official CLI for Claude."
-	pc.ExtraHeaders["anthropic-version"] = "2023-06-01"
-
-	value := pc.ExtraHeaders["anthropic-beta"]
-	const want = "oauth-2025-04-20"
-	if !strings.Contains(value, want) {
-		if value != "" {
-			value += ","
-		}
-		value += want
-	}
-	pc.ExtraHeaders["anthropic-beta"] = value
-}
-
 func (pc *ProviderConfig) SetupGitHubCopilot() {
 	maps.Copy(pc.ExtraHeaders, copilot.Headers())
 }
@@ -527,6 +511,25 @@ func (c *Config) SetConfigField(key string, value any) error {
 	return nil
 }
 
+func (c *Config) RemoveConfigField(key string) error {
+	data, err := os.ReadFile(c.dataConfigDir)
+	if err != nil {
+		return fmt.Errorf("failed to read config file: %w", err)
+	}
+
+	newValue, err := sjson.Delete(string(data), key)
+	if err != nil {
+		return fmt.Errorf("failed to delete config field %s: %w", key, err)
+	}
+	if err := os.MkdirAll(filepath.Dir(c.dataConfigDir), 0o755); err != nil {
+		return fmt.Errorf("failed to create config directory %q: %w", c.dataConfigDir, err)
+	}
+	if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o600); err != nil {
+		return fmt.Errorf("failed to write config file: %w", err)
+	}
+	return nil
+}
+
 // RefreshOAuthToken refreshes the OAuth token for the given provider.
 func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error {
 	providerConfig, exists := c.Providers.Get(providerID)
@@ -541,8 +544,6 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
 	var newToken *oauth.Token
 	var refreshErr error
 	switch providerID {
-	case string(catwalk.InferenceProviderAnthropic):
-		newToken, refreshErr = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
 	case string(catwalk.InferenceProviderCopilot):
 		newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
 	case hyperp.Name:
@@ -559,8 +560,6 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error
 	providerConfig.APIKey = newToken.AccessToken
 
 	switch providerID {
-	case string(catwalk.InferenceProviderAnthropic):
-		providerConfig.SetupClaudeCode()
 	case string(catwalk.InferenceProviderCopilot):
 		providerConfig.SetupGitHubCopilot()
 	}
@@ -599,8 +598,6 @@ func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
 			providerConfig.APIKey = v.AccessToken
 			providerConfig.OAuthToken = v
 			switch providerID {
-			case string(catwalk.InferenceProviderAnthropic):
-				providerConfig.SetupClaudeCode()
 			case string(catwalk.InferenceProviderCopilot):
 				providerConfig.SetupGitHubCopilot()
 			}

internal/config/load.go 🔗

@@ -202,11 +202,12 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
 
 		switch {
 		case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
-			prepared.SetupClaudeCode()
-		case p.ID == catwalk.InferenceProviderCopilot:
-			if config.OAuthToken != nil {
-				prepared.SetupGitHubCopilot()
-			}
+			// Claude Code subscription is not supported anymore. Remove to show onboarding.
+			c.RemoveConfigField("providers.anthropic")
+			c.Providers.Del(string(p.ID))
+			continue
+		case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
+			prepared.SetupGitHubCopilot()
 		}
 
 		switch p.ID {
@@ -365,10 +366,11 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	slices.Sort(c.Options.ContextPaths)
 	c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
 
-	// Add the default skills directory if not already present.
-	defaultSkillsDir := GlobalSkillsDir()
-	if !slices.Contains(c.Options.SkillsPaths, defaultSkillsDir) {
-		c.Options.SkillsPaths = append([]string{defaultSkillsDir}, c.Options.SkillsPaths...)
+	// Add the default skills directories if not already present.
+	for _, dir := range GlobalSkillsDirs() {
+		if !slices.Contains(c.Options.SkillsPaths, dir) {
+			c.Options.SkillsPaths = append(c.Options.SkillsPaths, dir)
+		}
 	}
 
 	if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
@@ -746,24 +748,29 @@ func isInsideWorktree() bool {
 	return err == nil && strings.TrimSpace(string(bts)) == "true"
 }
 
-// GlobalSkillsDir returns the default directory for Agent Skills.
-// Skills in this directory are auto-discovered and their files can be read
+// GlobalSkillsDirs returns the default directories for Agent Skills.
+// Skills in these directories are auto-discovered and their files can be read
 // without permission prompts.
-func GlobalSkillsDir() string {
+func GlobalSkillsDirs() []string {
 	if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
-		return crushSkills
-	}
-	if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
-		return filepath.Join(xdgConfigHome, appName, "skills")
+		return []string{crushSkills}
 	}
 
-	if runtime.GOOS == "windows" {
-		localAppData := cmp.Or(
+	// Determine the base config directory.
+	var configBase string
+	if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
+		configBase = xdgConfigHome
+	} else if runtime.GOOS == "windows" {
+		configBase = cmp.Or(
 			os.Getenv("LOCALAPPDATA"),
 			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
 		)
-		return filepath.Join(localAppData, appName, "skills")
+	} else {
+		configBase = filepath.Join(home.Dir(), ".config")
 	}
 
-	return filepath.Join(home.Dir(), ".config", appName, "skills")
+	return []string{
+		filepath.Join(configBase, appName, "skills"),
+		filepath.Join(configBase, "agents", "skills"),
+	}
 }

internal/event/event.go 🔗

@@ -15,6 +15,8 @@ import (
 const (
 	endpoint = "https://data.charm.land"
 	key      = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V"
+
+	nonInteractiveEventName = "NonInteractive"
 )
 
 var (
@@ -27,11 +29,11 @@ var (
 			Set("SHELL", filepath.Base(os.Getenv("SHELL"))).
 			Set("Version", version.Version).
 			Set("GoVersion", runtime.Version()).
-			Set("Interactive", false)
+			Set(nonInteractiveEventName, false)
 )
 
-func SetInteractive(interactive bool) {
-	baseProps = baseProps.Set("Interactive", interactive)
+func SetNonInteractive(nonInteractive bool) {
+	baseProps = baseProps.Set(nonInteractiveEventName, nonInteractive)
 }
 
 func Init() {

internal/filetracker/filetracker.go 🔗

@@ -0,0 +1,70 @@
+// Package filetracker tracks file read/write times to prevent editing files
+// that haven't been read, and to detect external modifications.
+//
+// TODO: Consider moving this to persistent storage (e.g., the database) to
+// preserve file access history across sessions.
+// We would need to make sure to handle the case where we reload a session and the underlying files did change.
+package filetracker
+
+import (
+	"sync"
+	"time"
+)
+
+// record tracks when a file was read/written.
+type record struct {
+	path      string
+	readTime  time.Time
+	writeTime time.Time
+}
+
+var (
+	records     = make(map[string]record)
+	recordMutex sync.RWMutex
+)
+
+// RecordRead records when a file was read.
+func RecordRead(path string) {
+	recordMutex.Lock()
+	defer recordMutex.Unlock()
+
+	rec, exists := records[path]
+	if !exists {
+		rec = record{path: path}
+	}
+	rec.readTime = time.Now()
+	records[path] = rec
+}
+
+// LastReadTime returns when a file was last read. Returns zero time if never
+// read.
+func LastReadTime(path string) time.Time {
+	recordMutex.RLock()
+	defer recordMutex.RUnlock()
+
+	rec, exists := records[path]
+	if !exists {
+		return time.Time{}
+	}
+	return rec.readTime
+}
+
+// RecordWrite records when a file was written.
+func RecordWrite(path string) {
+	recordMutex.Lock()
+	defer recordMutex.Unlock()
+
+	rec, exists := records[path]
+	if !exists {
+		rec = record{path: path}
+	}
+	rec.writeTime = time.Now()
+	records[path] = rec
+}
+
+// Reset clears all file tracking records. Useful for testing.
+func Reset() {
+	recordMutex.Lock()
+	defer recordMutex.Unlock()
+	records = make(map[string]record)
+}

internal/oauth/claude/challenge.go 🔗

@@ -1,28 +0,0 @@
-package claude
-
-import (
-	"crypto/rand"
-	"crypto/sha256"
-	"encoding/base64"
-	"strings"
-)
-
-// GetChallenge generates a PKCE verifier and its corresponding challenge.
-func GetChallenge() (verifier string, challenge string, err error) {
-	bytes := make([]byte, 32)
-	if _, err := rand.Read(bytes); err != nil {
-		return "", "", err
-	}
-	verifier = encodeBase64(bytes)
-	hash := sha256.Sum256([]byte(verifier))
-	challenge = encodeBase64(hash[:])
-	return verifier, challenge, nil
-}
-
-func encodeBase64(input []byte) (encoded string) {
-	encoded = base64.StdEncoding.EncodeToString(input)
-	encoded = strings.ReplaceAll(encoded, "=", "")
-	encoded = strings.ReplaceAll(encoded, "+", "-")
-	encoded = strings.ReplaceAll(encoded, "/", "_")
-	return encoded
-}

internal/oauth/claude/oauth.go 🔗

@@ -1,126 +0,0 @@
-package claude
-
-import (
-	"bytes"
-	"context"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"net/url"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/crush/internal/oauth"
-)
-
-const clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
-
-// AuthorizeURL returns the Claude Code Max OAuth2 authorization URL.
-func AuthorizeURL(verifier, challenge string) (string, error) {
-	u, err := url.Parse("https://claude.ai/oauth/authorize")
-	if err != nil {
-		return "", err
-	}
-	q := u.Query()
-	q.Set("response_type", "code")
-	q.Set("client_id", clientId)
-	q.Set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
-	q.Set("scope", "org:create_api_key user:profile user:inference")
-	q.Set("code_challenge", challenge)
-	q.Set("code_challenge_method", "S256")
-	q.Set("state", verifier)
-	u.RawQuery = q.Encode()
-	return u.String(), nil
-}
-
-// ExchangeToken exchanges the authorization code for an OAuth2 token.
-func ExchangeToken(ctx context.Context, code, verifier string) (*oauth.Token, error) {
-	code = strings.TrimSpace(code)
-	parts := strings.SplitN(code, "#", 2)
-	pure := parts[0]
-	state := ""
-	if len(parts) > 1 {
-		state = parts[1]
-	}
-
-	reqBody := map[string]string{
-		"code":          pure,
-		"state":         state,
-		"grant_type":    "authorization_code",
-		"client_id":     clientId,
-		"redirect_uri":  "https://console.anthropic.com/oauth/code/callback",
-		"code_verifier": verifier,
-	}
-
-	resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("claude code max: failed to exchange token: status %d body %q", resp.StatusCode, string(body))
-	}
-
-	var token oauth.Token
-	if err := json.Unmarshal(body, &token); err != nil {
-		return nil, err
-	}
-	token.SetExpiresAt()
-	return &token, nil
-}
-
-// RefreshToken refreshes the OAuth2 token using the provided refresh token.
-func RefreshToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {
-	reqBody := map[string]string{
-		"grant_type":    "refresh_token",
-		"refresh_token": refreshToken,
-		"client_id":     clientId,
-	}
-
-	resp, err := request(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", reqBody)
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("claude code max: failed to refresh token: status %d body %q", resp.StatusCode, string(body))
-	}
-
-	var token oauth.Token
-	if err := json.Unmarshal(body, &token); err != nil {
-		return nil, err
-	}
-	token.SetExpiresAt()
-	return &token, nil
-}
-
-func request(ctx context.Context, method, url string, body any) (*http.Response, error) {
-	date, err := json.Marshal(body)
-	if err != nil {
-		return nil, err
-	}
-
-	req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(date))
-	if err != nil {
-		return nil, err
-	}
-
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("User-Agent", "anthropic")
-
-	client := &http.Client{Timeout: 30 * time.Second}
-	return client.Do(req)
-}

internal/tui/components/chat/editor/editor.go 🔗

@@ -18,6 +18,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/session"
@@ -202,11 +203,20 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				m.currentQuery = ""
 				m.completionsStartIndex = 0
 			}
+			absPath, _ := filepath.Abs(item.Path)
+			// Skip attachment if file was already read and hasn't been modified.
+			lastRead := filetracker.LastReadTime(absPath)
+			if !lastRead.IsZero() {
+				if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
+					return m, nil
+				}
+			}
 			content, err := os.ReadFile(item.Path)
 			if err != nil {
 				// if it fails, let the LLM handle it later.
 				return m, nil
 			}
+			filetracker.RecordRead(absPath)
 			m.attachments = append(m.attachments, message.Attachment{
 				FilePath: item.Path,
 				FileName: filepath.Base(item.Path),
@@ -247,6 +257,7 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		if !attachment.IsText() && !attachment.IsImage() {
 			return m, util.ReportWarn("Invalid file content type: " + mimeType)
 		}
+		m.textarea.InsertString(attachment.FileName)
 		return m, util.CmdHandler(filepicker.FilePickedMsg{
 			Attachment: attachment,
 		})

internal/tui/components/chat/splash/splash.go 🔗

@@ -9,7 +9,6 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
-	"github.com/atotto/clipboard"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
@@ -18,7 +17,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
@@ -47,18 +45,6 @@ type Splash interface {
 	// IsAPIKeyValid returns whether the API key is valid
 	IsAPIKeyValid() bool
 
-	// IsShowingClaudeAuthMethodChooser returns whether showing Claude auth method chooser
-	IsShowingClaudeAuthMethodChooser() bool
-
-	// IsShowingClaudeOAuth2 returns whether showing Claude OAuth2 flow
-	IsShowingClaudeOAuth2() bool
-
-	// IsClaudeOAuthURLState returns whether in OAuth URL state
-	IsClaudeOAuthURLState() bool
-
-	// IsClaudeOAuthComplete returns whether Claude OAuth flow is complete
-	IsClaudeOAuthComplete() bool
-
 	// IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow
 	IsShowingHyperOAuth2() bool
 
@@ -103,12 +89,6 @@ type splashCmp struct {
 	// Copilot device flow state
 	copilotDeviceFlow     *copilot.DeviceFlow
 	showCopilotDeviceFlow bool
-
-	// Claude state
-	claudeAuthMethodChooser     *claude.AuthMethodChooser
-	claudeOAuth2                *claude.OAuth2
-	showClaudeAuthMethodChooser bool
-	showClaudeOAuth2            bool
 }
 
 func New() Splash {
@@ -134,9 +114,6 @@ func New() Splash {
 		modelList:    modelList,
 		apiKeyInput:  apiKeyInput,
 		selectedNo:   false,
-
-		claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
-		claudeOAuth2:            claude.NewOAuth2(),
 	}
 }
 
@@ -158,8 +135,6 @@ func (s *splashCmp) Init() tea.Cmd {
 	return tea.Batch(
 		s.modelList.Init(),
 		s.apiKeyInput.Init(),
-		s.claudeAuthMethodChooser.Init(),
-		s.claudeOAuth2.Init(),
 	)
 }
 
@@ -176,7 +151,6 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
 	s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
 	listWidth := min(60, width)
 	s.apiKeyInput.SetWidth(width - 2)
-	s.claudeAuthMethodChooser.SetWidth(min(width-2, 60))
 	return s.modelList.SetSize(listWidth, s.listHeight)
 }
 
@@ -185,24 +159,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		return s, s.SetSize(msg.Width, msg.Height)
-	case claude.ValidationCompletedMsg:
-		var cmds []tea.Cmd
-		u, cmd := s.claudeOAuth2.Update(msg)
-		s.claudeOAuth2 = u.(*claude.OAuth2)
-		cmds = append(cmds, cmd)
-
-		if msg.State == claude.OAuthValidationStateValid {
-			cmds = append(
-				cmds,
-				s.saveAPIKeyAndContinue(msg.Token, false),
-				func() tea.Msg {
-					time.Sleep(5 * time.Second)
-					return claude.AuthenticationCompleteMsg{}
-				},
-			)
-		}
-
-		return s, tea.Batch(cmds...)
 	case hyper.DeviceFlowCompletedMsg:
 		s.showHyperDeviceFlow = false
 		return s, s.saveAPIKeyAndContinue(msg.Token, true)
@@ -223,10 +179,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	case copilot.DeviceFlowCompletedMsg:
 		s.showCopilotDeviceFlow = false
 		return s, s.saveAPIKeyAndContinue(msg.Token, true)
-	case claude.AuthenticationCompleteMsg:
-		s.showClaudeAuthMethodChooser = false
-		s.showClaudeOAuth2 = false
-		return s, util.CmdHandler(OnboardingCompleteMsg{})
 	case models.APIKeyStateChangeMsg:
 		u, cmd := s.apiKeyInput.Update(msg)
 		s.apiKeyInput = u.(*models.APIKeyInput)
@@ -246,34 +198,8 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			return s, s.hyperDeviceFlow.CopyCode()
 		case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow:
 			return s, s.copilotDeviceFlow.CopyCode()
-		case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL:
-			return s, tea.Sequence(
-				tea.SetClipboard(s.claudeOAuth2.URL),
-				func() tea.Msg {
-					_ = clipboard.WriteAll(s.claudeOAuth2.URL)
-					return nil
-				},
-				util.ReportInfo("URL copied to clipboard"),
-			)
-		case key.Matches(msg, s.keyMap.Copy) && s.showClaudeAuthMethodChooser:
-			u, cmd := s.claudeAuthMethodChooser.Update(msg)
-			s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
-			return s, cmd
-		case key.Matches(msg, s.keyMap.Copy) && s.showClaudeOAuth2:
-			u, cmd := s.claudeOAuth2.Update(msg)
-			s.claudeOAuth2 = u.(*claude.OAuth2)
-			return s, cmd
 		case key.Matches(msg, s.keyMap.Back):
 			switch {
-			case s.showClaudeAuthMethodChooser:
-				s.claudeAuthMethodChooser.SetDefaults()
-				s.showClaudeAuthMethodChooser = false
-				return s, nil
-			case s.showClaudeOAuth2:
-				s.claudeOAuth2.SetDefaults()
-				s.showClaudeOAuth2 = false
-				s.showClaudeAuthMethodChooser = true
-				return s, nil
 			case s.showHyperDeviceFlow:
 				s.hyperDeviceFlow = nil
 				s.showHyperDeviceFlow = false
@@ -285,9 +211,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			case s.isAPIKeyValid:
 				return s, nil
 			case s.needsAPIKey:
-				if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
-					s.showClaudeAuthMethodChooser = true
-				}
 				s.needsAPIKey = false
 				s.selectedModel = nil
 				s.isAPIKeyValid = false
@@ -297,28 +220,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			}
 		case key.Matches(msg, s.keyMap.Select):
 			switch {
-			case s.showClaudeAuthMethodChooser:
-				selectedItem := s.modelList.SelectedModel()
-				if selectedItem == nil {
-					return s, nil
-				}
-
-				switch s.claudeAuthMethodChooser.State {
-				case claude.AuthMethodAPIKey:
-					s.showClaudeAuthMethodChooser = false
-					s.needsAPIKey = true
-					s.selectedModel = selectedItem
-					s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
-				case claude.AuthMethodOAuth2:
-					s.selectedModel = selectedItem
-					s.showClaudeAuthMethodChooser = false
-					s.showClaudeOAuth2 = true
-				}
-				return s, nil
-			case s.showClaudeOAuth2:
-				m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
-				s.claudeOAuth2 = m2.(*claude.OAuth2)
-				return s, cmd2
 			case s.showHyperDeviceFlow:
 				return s, s.hyperDeviceFlow.CopyCodeAndOpenURL()
 			case s.showCopilotDeviceFlow:
@@ -336,9 +237,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 					return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
 				} else {
 					switch selectedItem.Provider.ID {
-					case catwalk.InferenceProviderAnthropic:
-						s.showClaudeAuthMethodChooser = true
-						return s, nil
 					case hyperp.Name:
 						s.selectedModel = selectedItem
 						s.showHyperDeviceFlow = true
@@ -407,10 +305,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				return s, s.initializeProject()
 			}
 		case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
-			if s.showClaudeAuthMethodChooser {
-				s.claudeAuthMethodChooser.ToggleChoice()
-				return s, nil
-			}
 			if s.needsAPIKey {
 				u, cmd := s.apiKeyInput.Update(msg)
 				s.apiKeyInput = u.(*models.APIKeyInput)
@@ -452,14 +346,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			}
 		default:
 			switch {
-			case s.showClaudeAuthMethodChooser:
-				u, cmd := s.claudeAuthMethodChooser.Update(msg)
-				s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
-				return s, cmd
-			case s.showClaudeOAuth2:
-				u, cmd := s.claudeOAuth2.Update(msg)
-				s.claudeOAuth2 = u.(*claude.OAuth2)
-				return s, cmd
 			case s.showHyperDeviceFlow:
 				u, cmd := s.hyperDeviceFlow.Update(msg)
 				s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
@@ -480,10 +366,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 	case tea.PasteMsg:
 		switch {
-		case s.showClaudeOAuth2:
-			u, cmd := s.claudeOAuth2.Update(msg)
-			s.claudeOAuth2 = u.(*claude.OAuth2)
-			return s, cmd
 		case s.showHyperDeviceFlow:
 			u, cmd := s.hyperDeviceFlow.Update(msg)
 			s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
@@ -503,10 +385,6 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 	case spinner.TickMsg:
 		switch {
-		case s.showClaudeOAuth2:
-			u, cmd := s.claudeOAuth2.Update(msg)
-			s.claudeOAuth2 = u.(*claude.OAuth2)
-			return s, cmd
 		case s.showHyperDeviceFlow:
 			u, cmd := s.hyperDeviceFlow.Update(msg)
 			s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
@@ -655,38 +533,6 @@ func (s *splashCmp) View() string {
 	var content string
 
 	switch {
-	case s.showClaudeAuthMethodChooser:
-		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
-		chooserView := s.claudeAuthMethodChooser.View()
-		authMethodSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
-				"",
-				chooserView,
-			),
-		)
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			s.logoRendered,
-			authMethodSelector,
-		)
-	case s.showClaudeOAuth2:
-		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
-		oauth2View := s.claudeOAuth2.View()
-		oauthSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Anthropic"),
-				"",
-				oauth2View,
-			),
-		)
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			s.logoRendered,
-			oauthSelector,
-		)
 	case s.showHyperDeviceFlow:
 		remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
 		hyperView := s.hyperDeviceFlow.View()
@@ -816,14 +662,6 @@ func (s *splashCmp) View() string {
 
 func (s *splashCmp) Cursor() *tea.Cursor {
 	switch {
-	case s.showClaudeAuthMethodChooser:
-		return nil
-	case s.showClaudeOAuth2:
-		if cursor := s.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
-			cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
-			return s.moveCursor(cursor)
-		}
-		return nil
 	case s.needsAPIKey:
 		cursor := s.apiKeyInput.Cursor()
 		if cursor != nil {
@@ -894,16 +732,10 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
 	}
 	// Calculate the correct Y offset based on current state
 	logoHeight := lipgloss.Height(s.logoRendered)
-	if s.needsAPIKey || s.showClaudeOAuth2 {
-		var view string
-		if s.needsAPIKey {
-			view = s.apiKeyInput.View()
-		} else {
-			view = s.claudeOAuth2.View()
-		}
+	if s.needsAPIKey {
 		infoSectionHeight := lipgloss.Height(s.infoSection())
 		baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
-		remainingHeight := s.height - baseOffset - lipgloss.Height(view) - SplashScreenPaddingY
+		remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
 		offset := baseOffset + remainingHeight
 		cursor.Y += offset
 		cursor.X += 1
@@ -926,20 +758,6 @@ func (s *splashCmp) logoGap() int {
 // Bindings implements SplashPage.
 func (s *splashCmp) Bindings() []key.Binding {
 	switch {
-	case s.showClaudeAuthMethodChooser:
-		return []key.Binding{
-			s.keyMap.Select,
-			s.keyMap.Tab,
-			s.keyMap.Back,
-		}
-	case s.showClaudeOAuth2:
-		bindings := []key.Binding{
-			s.keyMap.Select,
-		}
-		if s.claudeOAuth2.State == claude.OAuthStateURL {
-			bindings = append(bindings, s.keyMap.Copy)
-		}
-		return bindings
 	case s.needsAPIKey:
 		return []key.Binding{
 			s.keyMap.Select,
@@ -1047,22 +865,6 @@ func (s *splashCmp) IsAPIKeyValid() bool {
 	return s.isAPIKeyValid
 }
 
-func (s *splashCmp) IsShowingClaudeAuthMethodChooser() bool {
-	return s.showClaudeAuthMethodChooser
-}
-
-func (s *splashCmp) IsShowingClaudeOAuth2() bool {
-	return s.showClaudeOAuth2
-}
-
-func (s *splashCmp) IsClaudeOAuthURLState() bool {
-	return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateURL
-}
-
-func (s *splashCmp) IsClaudeOAuthComplete() bool {
-	return s.showClaudeOAuth2 && s.claudeOAuth2.State == claude.OAuthStateCode && s.claudeOAuth2.ValidationState == claude.OAuthValidationStateValid
-}
-
 func (s *splashCmp) IsShowingHyperOAuth2() bool {
 	return s.showHyperDeviceFlow
 }

internal/tui/components/dialogs/claude/method.go 🔗

@@ -1,115 +0,0 @@
-package claude
-
-import (
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type AuthMethod int
-
-const (
-	AuthMethodAPIKey AuthMethod = iota
-	AuthMethodOAuth2
-)
-
-type AuthMethodChooser struct {
-	State        AuthMethod
-	width        int
-	isOnboarding bool
-}
-
-func NewAuthMethodChooser() *AuthMethodChooser {
-	return &AuthMethodChooser{
-		State: AuthMethodOAuth2,
-	}
-}
-
-func (a *AuthMethodChooser) Init() tea.Cmd {
-	return nil
-}
-
-func (a *AuthMethodChooser) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	return a, nil
-}
-
-func (a *AuthMethodChooser) View() string {
-	t := styles.CurrentTheme()
-
-	white := lipgloss.NewStyle().Foreground(t.White)
-	primary := lipgloss.NewStyle().Foreground(t.Primary)
-	success := lipgloss.NewStyle().Foreground(t.Success)
-
-	titleStyle := white
-	if a.isOnboarding {
-		titleStyle = primary
-	}
-
-	question := lipgloss.
-		NewStyle().
-		Margin(0, 1).
-		Render(titleStyle.Render("How would you like to authenticate with ") + success.Render("Anthropic") + titleStyle.Render("?"))
-
-	squareWidth := (a.width - 2) / 2
-	squareHeight := squareWidth / 3
-	if isOdd(squareHeight) {
-		squareHeight++
-	}
-
-	square := lipgloss.NewStyle().
-		Width(squareWidth).
-		Height(squareHeight).
-		Margin(0, 0).
-		Border(lipgloss.RoundedBorder())
-
-	squareText := lipgloss.NewStyle().
-		Width(squareWidth - 2).
-		Height(squareHeight).
-		Align(lipgloss.Center).
-		AlignVertical(lipgloss.Center)
-
-	oauthBorder := t.AuthBorderSelected
-	oauthText := t.AuthTextSelected
-	apiKeyBorder := t.AuthBorderUnselected
-	apiKeyText := t.AuthTextUnselected
-
-	if a.State == AuthMethodAPIKey {
-		oauthBorder, apiKeyBorder = apiKeyBorder, oauthBorder
-		oauthText, apiKeyText = apiKeyText, oauthText
-	}
-
-	return lipgloss.JoinVertical(
-		lipgloss.Left,
-		question,
-		"",
-		lipgloss.JoinHorizontal(
-			lipgloss.Center,
-			square.MarginLeft(1).
-				Inherit(oauthBorder).Render(squareText.Inherit(oauthText).Render("Claude Account\nwith Subscription")),
-			square.MarginRight(1).
-				Inherit(apiKeyBorder).Render(squareText.Inherit(apiKeyText).Render("API Key")),
-		),
-	)
-}
-
-func (a *AuthMethodChooser) SetDefaults() {
-	a.State = AuthMethodOAuth2
-}
-
-func (a *AuthMethodChooser) SetWidth(w int) {
-	a.width = w
-}
-
-func (a *AuthMethodChooser) ToggleChoice() {
-	switch a.State {
-	case AuthMethodAPIKey:
-		a.State = AuthMethodOAuth2
-	case AuthMethodOAuth2:
-		a.State = AuthMethodAPIKey
-	}
-}
-
-func isOdd(n int) bool {
-	return n%2 != 0
-}

internal/tui/components/dialogs/claude/oauth.go 🔗

@@ -1,267 +0,0 @@
-package claude
-
-import (
-	"context"
-	"fmt"
-	"net/url"
-
-	"charm.land/bubbles/v2/spinner"
-	"charm.land/bubbles/v2/textinput"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/oauth"
-	"github.com/charmbracelet/crush/internal/oauth/claude"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/pkg/browser"
-	"github.com/zeebo/xxh3"
-)
-
-type OAuthState int
-
-const (
-	OAuthStateURL OAuthState = iota
-	OAuthStateCode
-)
-
-type OAuthValidationState int
-
-const (
-	OAuthValidationStateNone OAuthValidationState = iota
-	OAuthValidationStateVerifying
-	OAuthValidationStateValid
-	OAuthValidationStateError
-)
-
-type ValidationCompletedMsg struct {
-	State OAuthValidationState
-	Token *oauth.Token
-}
-
-type AuthenticationCompleteMsg struct{}
-
-type OAuth2 struct {
-	State           OAuthState
-	ValidationState OAuthValidationState
-	width           int
-	isOnboarding    bool
-
-	// URL page
-	err       error
-	verifier  string
-	challenge string
-	URL       string
-	urlId     string
-	token     *oauth.Token
-
-	// Code input page
-	CodeInput textinput.Model
-	spinner   spinner.Model
-}
-
-func NewOAuth2() *OAuth2 {
-	return &OAuth2{
-		State: OAuthStateURL,
-	}
-}
-
-func (o *OAuth2) Init() tea.Cmd {
-	t := styles.CurrentTheme()
-
-	verifier, challenge, err := claude.GetChallenge()
-	if err != nil {
-		o.err = err
-		return nil
-	}
-
-	url, err := claude.AuthorizeURL(verifier, challenge)
-	if err != nil {
-		o.err = err
-		return nil
-	}
-
-	o.verifier = verifier
-	o.challenge = challenge
-	o.URL = url
-
-	h := xxh3.New()
-	_, _ = h.WriteString(o.URL)
-	o.urlId = fmt.Sprintf("id=%x", h.Sum(nil))
-
-	o.CodeInput = textinput.New()
-	o.CodeInput.Placeholder = "Paste or type"
-	o.CodeInput.SetVirtualCursor(false)
-	o.CodeInput.Prompt = "> "
-	o.CodeInput.SetStyles(t.S().TextInput)
-	o.CodeInput.SetWidth(50)
-
-	o.spinner = spinner.New(
-		spinner.WithSpinner(spinner.Dot),
-		spinner.WithStyle(t.S().Base.Foreground(t.Green)),
-	)
-
-	return nil
-}
-
-func (o *OAuth2) Update(msg tea.Msg) (util.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-
-	switch msg := msg.(type) {
-	case ValidationCompletedMsg:
-		o.ValidationState = msg.State
-		o.token = msg.Token
-		switch o.ValidationState {
-		case OAuthValidationStateError:
-			o.CodeInput.Focus()
-		}
-		o.updatePrompt()
-	}
-
-	if o.ValidationState == OAuthValidationStateVerifying {
-		var cmd tea.Cmd
-		o.spinner, cmd = o.spinner.Update(msg)
-		cmds = append(cmds, cmd)
-		o.updatePrompt()
-	}
-	{
-		var cmd tea.Cmd
-		o.CodeInput, cmd = o.CodeInput.Update(msg)
-		cmds = append(cmds, cmd)
-	}
-
-	return o, tea.Batch(cmds...)
-}
-
-func (o *OAuth2) ValidationConfirm() (util.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-
-	switch {
-	case o.State == OAuthStateURL:
-		_ = browser.OpenURL(o.URL)
-		o.State = OAuthStateCode
-		cmds = append(cmds, o.CodeInput.Focus())
-	case o.ValidationState == OAuthValidationStateNone || o.ValidationState == OAuthValidationStateError:
-		o.CodeInput.Blur()
-		o.ValidationState = OAuthValidationStateVerifying
-		cmds = append(cmds, o.spinner.Tick, o.validateCode)
-	case o.ValidationState == OAuthValidationStateValid:
-		cmds = append(cmds, func() tea.Msg { return AuthenticationCompleteMsg{} })
-	}
-
-	o.updatePrompt()
-	return o, tea.Batch(cmds...)
-}
-
-func (o *OAuth2) View() string {
-	t := styles.CurrentTheme()
-
-	whiteStyle := lipgloss.NewStyle().Foreground(t.White)
-	primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
-	successStyle := lipgloss.NewStyle().Foreground(t.Success)
-	errorStyle := lipgloss.NewStyle().Foreground(t.Error)
-
-	titleStyle := whiteStyle
-	if o.isOnboarding {
-		titleStyle = primaryStyle
-	}
-
-	switch {
-	case o.err != nil:
-		return lipgloss.NewStyle().
-			Margin(0, 1).
-			Foreground(t.Error).
-			Render(o.err.Error())
-	case o.State == OAuthStateURL:
-		heading := lipgloss.
-			NewStyle().
-			Margin(0, 1).
-			Render(titleStyle.Render("Press enter key to open the following ") + successStyle.Render("URL") + titleStyle.Render(":"))
-
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			heading,
-			"",
-			lipgloss.NewStyle().
-				Margin(0, 1).
-				Foreground(t.FgMuted).
-				Hyperlink(o.URL, o.urlId).
-				Render(o.displayUrl()),
-		)
-	case o.State == OAuthStateCode:
-		var heading string
-
-		switch o.ValidationState {
-		case OAuthValidationStateNone:
-			st := lipgloss.NewStyle().Margin(0, 1)
-			heading = st.Render(titleStyle.Render("Enter the ") + successStyle.Render("code") + titleStyle.Render(" you received."))
-		case OAuthValidationStateVerifying:
-			heading = titleStyle.Margin(0, 1).Render("Verifying...")
-		case OAuthValidationStateValid:
-			heading = successStyle.Margin(0, 1).Render("Validated.")
-		case OAuthValidationStateError:
-			heading = errorStyle.Margin(0, 1).Render("Invalid. Try again?")
-		}
-
-		return lipgloss.JoinVertical(
-			lipgloss.Left,
-			heading,
-			"",
-			" "+o.CodeInput.View(),
-		)
-	default:
-		panic("claude oauth2: invalid state")
-	}
-}
-
-func (o *OAuth2) SetDefaults() {
-	o.State = OAuthStateURL
-	o.ValidationState = OAuthValidationStateNone
-	o.CodeInput.SetValue("")
-	o.err = nil
-}
-
-func (o *OAuth2) SetWidth(w int) {
-	o.width = w
-	o.CodeInput.SetWidth(w - 4)
-}
-
-func (o *OAuth2) SetError(err error) {
-	o.err = err
-}
-
-func (o *OAuth2) validateCode() tea.Msg {
-	token, err := claude.ExchangeToken(context.Background(), o.CodeInput.Value(), o.verifier)
-	if err != nil || token == nil {
-		return ValidationCompletedMsg{State: OAuthValidationStateError}
-	}
-	return ValidationCompletedMsg{State: OAuthValidationStateValid, Token: token}
-}
-
-func (o *OAuth2) updatePrompt() {
-	switch o.ValidationState {
-	case OAuthValidationStateNone:
-		o.CodeInput.Prompt = "> "
-	case OAuthValidationStateVerifying:
-		o.CodeInput.Prompt = o.spinner.View() + " "
-	case OAuthValidationStateValid:
-		o.CodeInput.Prompt = styles.CheckIcon + " "
-	case OAuthValidationStateError:
-		o.CodeInput.Prompt = styles.ErrorIcon + " "
-	}
-}
-
-// Remove query params for display
-// e.g., "https://claude.ai/oauth/authorize?..." -> "https://claude.ai/oauth/authorize..."
-func (o *OAuth2) displayUrl() string {
-	parsed, err := url.Parse(o.URL)
-	if err != nil {
-		return o.URL
-	}
-
-	if parsed.RawQuery != "" {
-		parsed.RawQuery = ""
-		return parsed.String() + "..."
-	}
-
-	return o.URL
-}

internal/tui/components/dialogs/models/keys.go 🔗

@@ -18,11 +18,6 @@ type KeyMap struct {
 	isHyperDeviceFlow    bool
 	isCopilotDeviceFlow  bool
 	isCopilotUnavailable bool
-
-	isClaudeAuthChoiceHelp    bool
-	isClaudeOAuthHelp         bool
-	isClaudeOAuthURLState     bool
-	isClaudeOAuthHelpComplete bool
 }
 
 func DefaultKeyMap() KeyMap {
@@ -100,58 +95,6 @@ func (k KeyMap) ShortHelp() []key.Binding {
 			k.Close,
 		}
 	}
-	if k.isClaudeAuthChoiceHelp {
-		return []key.Binding{
-			key.NewBinding(
-				key.WithKeys("left", "right", "h", "l"),
-				key.WithHelp("←→", "choose"),
-			),
-			key.NewBinding(
-				key.WithKeys("enter"),
-				key.WithHelp("enter", "accept"),
-			),
-			key.NewBinding(
-				key.WithKeys("esc"),
-				key.WithHelp("esc", "back"),
-			),
-		}
-	}
-	if k.isClaudeOAuthHelp {
-		if k.isClaudeOAuthHelpComplete {
-			return []key.Binding{
-				key.NewBinding(
-					key.WithKeys("enter"),
-					key.WithHelp("enter", "close"),
-				),
-			}
-		}
-
-		enterHelp := "submit"
-		if k.isClaudeOAuthURLState {
-			enterHelp = "open"
-		}
-
-		bindings := []key.Binding{
-			key.NewBinding(
-				key.WithKeys("enter"),
-				key.WithHelp("enter", enterHelp),
-			),
-		}
-
-		if k.isClaudeOAuthURLState {
-			bindings = append(bindings, key.NewBinding(
-				key.WithKeys("c"),
-				key.WithHelp("c", "copy url"),
-			))
-		}
-
-		bindings = append(bindings, key.NewBinding(
-			key.WithKeys("esc"),
-			key.WithHelp("esc", "back"),
-		))
-
-		return bindings
-	}
 	if k.isAPIKeyHelp && !k.isAPIKeyValid {
 		return []key.Binding{
 			key.NewBinding(

internal/tui/components/dialogs/models/models.go 🔗

@@ -10,13 +10,11 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
-	"github.com/atotto/clipboard"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
@@ -81,12 +79,6 @@ type modelDialogCmp struct {
 	// Copilot device flow state
 	copilotDeviceFlow     *copilot.DeviceFlow
 	showCopilotDeviceFlow bool
-
-	// Claude state
-	claudeAuthMethodChooser     *claude.AuthMethodChooser
-	claudeOAuth2                *claude.OAuth2
-	showClaudeAuthMethodChooser bool
-	showClaudeOAuth2            bool
 }
 
 func NewModelDialogCmp() ModelDialog {
@@ -111,9 +103,6 @@ func NewModelDialogCmp() ModelDialog {
 		width:       defaultWidth,
 		keyMap:      DefaultKeyMap(),
 		help:        help,
-
-		claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
-		claudeOAuth2:            claude.NewOAuth2(),
 	}
 }
 
@@ -121,8 +110,6 @@ func (m *modelDialogCmp) Init() tea.Cmd {
 	return tea.Batch(
 		m.modelList.Init(),
 		m.apiKeyInput.Init(),
-		m.claudeAuthMethodChooser.Init(),
-		m.claudeOAuth2.Init(),
 	)
 }
 
@@ -133,7 +120,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		m.wHeight = msg.Height
 		m.apiKeyInput.SetWidth(m.width - 2)
 		m.help.SetWidth(m.width - 2)
-		m.claudeAuthMethodChooser.SetWidth(m.width - 2)
 		return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
 	case APIKeyStateChangeMsg:
 		u, cmd := m.apiKeyInput.Update(msg)
@@ -157,20 +143,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		return m, nil
 	case copilot.DeviceFlowCompletedMsg:
 		return m, m.saveOauthTokenAndContinue(msg.Token, true)
-	case claude.ValidationCompletedMsg:
-		var cmds []tea.Cmd
-		u, cmd := m.claudeOAuth2.Update(msg)
-		m.claudeOAuth2 = u.(*claude.OAuth2)
-		cmds = append(cmds, cmd)
-
-		if msg.State == claude.OAuthValidationStateValid {
-			cmds = append(cmds, m.saveOauthTokenAndContinue(msg.Token, false))
-			m.keyMap.isClaudeOAuthHelpComplete = true
-		}
-
-		return m, tea.Batch(cmds...)
-	case claude.AuthenticationCompleteMsg:
-		return m, util.CmdHandler(dialogs.CloseDialogMsg{})
 	case tea.KeyPressMsg:
 		switch {
 		// Handle Hyper device flow keys
@@ -178,18 +150,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			return m, m.hyperDeviceFlow.CopyCode()
 		case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow:
 			return m, m.copilotDeviceFlow.CopyCode()
-		case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showClaudeOAuth2 && m.claudeOAuth2.State == claude.OAuthStateURL:
-			return m, tea.Sequence(
-				tea.SetClipboard(m.claudeOAuth2.URL),
-				func() tea.Msg {
-					_ = clipboard.WriteAll(m.claudeOAuth2.URL)
-					return nil
-				},
-				util.ReportInfo("URL copied to clipboard"),
-			)
-		case key.Matches(msg, m.keyMap.Choose) && m.showClaudeAuthMethodChooser:
-			m.claudeAuthMethodChooser.ToggleChoice()
-			return m, nil
 		case key.Matches(msg, m.keyMap.Select):
 			// If showing device flow, enter copies code and opens URL
 			if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
@@ -209,37 +169,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			}
 
 			askForApiKey := func() {
-				m.keyMap.isClaudeAuthChoiceHelp = false
-				m.keyMap.isClaudeOAuthHelp = false
 				m.keyMap.isAPIKeyHelp = true
 				m.showHyperDeviceFlow = false
 				m.showCopilotDeviceFlow = false
-				m.showClaudeAuthMethodChooser = false
 				m.needsAPIKey = true
 				m.selectedModel = selectedItem
 				m.selectedModelType = modelType
 				m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
 			}
 
-			if m.showClaudeAuthMethodChooser {
-				switch m.claudeAuthMethodChooser.State {
-				case claude.AuthMethodAPIKey:
-					askForApiKey()
-				case claude.AuthMethodOAuth2:
-					m.selectedModel = selectedItem
-					m.selectedModelType = modelType
-					m.showClaudeAuthMethodChooser = false
-					m.showClaudeOAuth2 = true
-					m.keyMap.isClaudeAuthChoiceHelp = false
-					m.keyMap.isClaudeOAuthHelp = true
-				}
-				return m, nil
-			}
-			if m.showClaudeOAuth2 {
-				m2, cmd2 := m.claudeOAuth2.ValidationConfirm()
-				m.claudeOAuth2 = m2.(*claude.OAuth2)
-				return m, cmd2
-			}
 			if m.isAPIKeyValid {
 				return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
 			}
@@ -298,10 +236,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				)
 			}
 			switch selectedItem.Provider.ID {
-			case catwalk.InferenceProviderAnthropic:
-				m.showClaudeAuthMethodChooser = true
-				m.keyMap.isClaudeAuthChoiceHelp = true
-				return m, nil
 			case hyperp.Name:
 				m.showHyperDeviceFlow = true
 				m.selectedModel = selectedItem
@@ -327,9 +261,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			return m, nil
 		case key.Matches(msg, m.keyMap.Tab):
 			switch {
-			case m.showClaudeAuthMethodChooser:
-				m.claudeAuthMethodChooser.ToggleChoice()
-				return m, nil
 			case m.needsAPIKey:
 				u, cmd := m.apiKeyInput.Update(msg)
 				m.apiKeyInput = u.(*APIKeyInput)
@@ -355,12 +286,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				}
 				m.showCopilotDeviceFlow = false
 				m.selectedModel = nil
-			case m.showClaudeAuthMethodChooser:
-				m.claudeAuthMethodChooser.SetDefaults()
-				m.showClaudeAuthMethodChooser = false
-				m.keyMap.isClaudeAuthChoiceHelp = false
-				m.keyMap.isClaudeOAuthHelp = false
-				return m, nil
 			case m.needsAPIKey:
 				if m.isAPIKeyValid {
 					return m, nil
@@ -377,14 +302,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			}
 		default:
 			switch {
-			case m.showClaudeAuthMethodChooser:
-				u, cmd := m.claudeAuthMethodChooser.Update(msg)
-				m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
-				return m, cmd
-			case m.showClaudeOAuth2:
-				u, cmd := m.claudeOAuth2.Update(msg)
-				m.claudeOAuth2 = u.(*claude.OAuth2)
-				return m, cmd
 			case m.needsAPIKey:
 				u, cmd := m.apiKeyInput.Update(msg)
 				m.apiKeyInput = u.(*APIKeyInput)
@@ -397,10 +314,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 	case tea.PasteMsg:
 		switch {
-		case m.showClaudeOAuth2:
-			u, cmd := m.claudeOAuth2.Update(msg)
-			m.claudeOAuth2 = u.(*claude.OAuth2)
-			return m, cmd
 		case m.needsAPIKey:
 			u, cmd := m.apiKeyInput.Update(msg)
 			m.apiKeyInput = u.(*APIKeyInput)
@@ -433,10 +346,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			u, cmd := m.copilotDeviceFlow.Update(msg)
 			m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
 			return m, cmd
-		case m.showClaudeOAuth2:
-			u, cmd := m.claudeOAuth2.Update(msg)
-			m.claudeOAuth2 = u.(*claude.OAuth2)
-			return m, cmd
 		default:
 			u, cmd := m.apiKeyInput.Update(msg)
 			m.apiKeyInput = u.(*APIKeyInput)
@@ -483,27 +392,6 @@ func (m *modelDialogCmp) View() string {
 	m.keyMap.isCopilotUnavailable = false
 
 	switch {
-	case m.showClaudeAuthMethodChooser:
-		chooserView := m.claudeAuthMethodChooser.View()
-		content := lipgloss.JoinVertical(
-			lipgloss.Left,
-			t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)),
-			chooserView,
-			"",
-			t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
-		)
-		return m.style().Render(content)
-	case m.showClaudeOAuth2:
-		m.keyMap.isClaudeOAuthURLState = m.claudeOAuth2.State == claude.OAuthStateURL
-		oauth2View := m.claudeOAuth2.View()
-		content := lipgloss.JoinVertical(
-			lipgloss.Left,
-			t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Let's Auth Anthropic", m.width-4)),
-			oauth2View,
-			"",
-			t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
-		)
-		return m.style().Render(content)
 	case m.needsAPIKey:
 		// Show API key input
 		m.keyMap.isAPIKeyHelp = true
@@ -540,16 +428,6 @@ func (m *modelDialogCmp) Cursor() *tea.Cursor {
 	if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
 		return m.copilotDeviceFlow.Cursor()
 	}
-	if m.showClaudeAuthMethodChooser {
-		return nil
-	}
-	if m.showClaudeOAuth2 {
-		if cursor := m.claudeOAuth2.CodeInput.Cursor(); cursor != nil {
-			cursor.Y += 2 // FIXME(@andreynering): Why do we need this?
-			return m.moveCursor(cursor)
-		}
-		return nil
-	}
 	if m.needsAPIKey {
 		cursor := m.apiKeyInput.Cursor()
 		if cursor != nil {

internal/tui/page/chat/chat.go 🔗

@@ -29,7 +29,6 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
@@ -337,9 +336,7 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		cmds = append(cmds, cmd)
 		return p, tea.Batch(cmds...)
 
-	case claude.ValidationCompletedMsg,
-		claude.AuthenticationCompleteMsg,
-		hyper.DeviceFlowCompletedMsg,
+	case hyper.DeviceFlowCompletedMsg,
 		hyper.DeviceAuthInitiatedMsg,
 		hyper.DeviceFlowErrorMsg,
 		copilot.DeviceAuthInitiatedMsg,
@@ -1037,53 +1034,8 @@ func (p *chatPage) Help() help.KeyMap {
 	var shortList []key.Binding
 	var fullList [][]key.Binding
 	switch {
-	case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
-		shortList = append(shortList,
-			// Choose auth method
-			key.NewBinding(
-				key.WithKeys("left", "right", "tab"),
-				key.WithHelp("←→/tab", "choose"),
-			),
-			// Accept selection
-			key.NewBinding(
-				key.WithKeys("enter"),
-				key.WithHelp("enter", "accept"),
-			),
-			// Go back
-			key.NewBinding(
-				key.WithKeys("esc", "alt+esc"),
-				key.WithHelp("esc", "back"),
-			),
-			// Quit
-			key.NewBinding(
-				key.WithKeys("ctrl+c"),
-				key.WithHelp("ctrl+c", "quit"),
-			),
-		)
-		// keep them the same
-		for _, v := range shortList {
-			fullList = append(fullList, []key.Binding{v})
-		}
-	case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
+	case p.isOnboarding:
 		switch {
-		case p.splash.IsClaudeOAuthURLState():
-			shortList = append(shortList,
-				key.NewBinding(
-					key.WithKeys("enter"),
-					key.WithHelp("enter", "open"),
-				),
-				key.NewBinding(
-					key.WithKeys("c"),
-					key.WithHelp("c", "copy url"),
-				),
-			)
-		case p.splash.IsClaudeOAuthComplete():
-			shortList = append(shortList,
-				key.NewBinding(
-					key.WithKeys("enter"),
-					key.WithHelp("enter", "continue"),
-				),
-			)
 		case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
 			shortList = append(shortList,
 				key.NewBinding(