From bf39639c3d152196384fc139161719202f04f2f8 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 5 Jan 2026 18:54:38 -0500 Subject: [PATCH 1/9] Revert "fix: prevent filename insertion when dragging attachments" (#1773) --- internal/tui/components/chat/editor/editor.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 1623ba806cd9e93bd1544aabbdb6a7e3f6604bcc..014d662ce59d1de84f16cd17057aa158c80384a7 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -247,6 +247,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, }) From ca21111b7672c775ddfab566063c2b9ad044dee3 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 5 Jan 2026 21:04:22 -0300 Subject: [PATCH 2/9] fix(posthog): correct bool prop name for non-interactive mode (#1771) Co-authored-by: Christian Rocha --- internal/cmd/run.go | 2 +- internal/event/event.go | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index fe214ecb570ac6c8c89fef46fd653661d540b4ba..6ebe5d79593bab6170e958ebdf26240d34327445 100644 --- a/internal/cmd/run.go +++ b/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) diff --git a/internal/event/event.go b/internal/event/event.go index 9dd7a6e607c4ca989a244cf2ac808dd50c2876f0..aa4f736547ece98a7913e73385058de1809ad37d 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -15,6 +15,8 @@ import ( const ( endpoint = "https://data.charm.land" key = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V" + + nonInteractiveEvenName = "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(nonInteractiveEvenName, false) ) -func SetInteractive(interactive bool) { - baseProps = baseProps.Set("Interactive", interactive) +func SetNonInteractive(nonInteractive bool) { + baseProps = baseProps.Set(nonInteractiveEvenName, nonInteractive) } func Init() { From 4cc3aeb4ee100841de75f58c8cbece3f3f4f241d Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 6 Jan 2026 10:46:57 -0300 Subject: [PATCH 3/9] chore: fix typo in const name --- internal/event/event.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/event/event.go b/internal/event/event.go index aa4f736547ece98a7913e73385058de1809ad37d..1dee1d49113684d80a5d3f390b7f912d22d7231d 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -16,7 +16,7 @@ const ( endpoint = "https://data.charm.land" key = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V" - nonInteractiveEvenName = "NonInteractive" + nonInteractiveEventName = "NonInteractive" ) var ( @@ -29,11 +29,11 @@ var ( Set("SHELL", filepath.Base(os.Getenv("SHELL"))). Set("Version", version.Version). Set("GoVersion", runtime.Version()). - Set(nonInteractiveEvenName, false) + Set(nonInteractiveEventName, false) ) func SetNonInteractive(nonInteractive bool) { - baseProps = baseProps.Set(nonInteractiveEvenName, nonInteractive) + baseProps = baseProps.Set(nonInteractiveEventName, nonInteractive) } func Init() { From 2bdac87e4a1505af3b17e1154b92bdf963c1df7b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 6 Jan 2026 15:27:54 +0100 Subject: [PATCH 4/9] fix: mark files that are attched as read (#1777) --- internal/agent/tools/edit.go | 21 +++--- internal/agent/tools/file.go | 53 -------------- 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/filetracker/filetracker.go | 70 +++++++++++++++++++ internal/tui/components/chat/editor/editor.go | 10 +++ 8 files changed, 106 insertions(+), 74 deletions(-) delete mode 100644 internal/agent/tools/file.go create mode 100644 internal/filetracker/filetracker.go diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index ccc115be2aa20113d8e3cbf91f1e644e90ce1b98..e4503e8127a750647c659353a018d36ee42643a1 100644 --- a/internal/agent/tools/edit.go +++ b/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), diff --git a/internal/agent/tools/file.go b/internal/agent/tools/file.go deleted file mode 100644 index 7f34fdc1f615031decf00706c58aac37a235b57e..0000000000000000000000000000000000000000 --- a/internal/agent/tools/file.go +++ /dev/null @@ -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 -} diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index c4a3aa200c8325db87a6bb8d860cade1a8e7025d..9136c37fadb914cb1c560e3fa5f2b6208fc3ead5 100644 --- a/internal/agent/tools/multiedit.go +++ b/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 { diff --git a/internal/agent/tools/multiedit_test.go b/internal/agent/tools/multiedit_test.go index 98cf6139f24f6ae323bc20736c5675003202f96a..36d0a0d469f67aa11cf36cd0bce3efffb4bab683 100644 --- a/internal/agent/tools/multiedit_test.go +++ b/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 diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 577fcad4dc0eaf65c46aec7e8c1e9a1b32c97062..7129a91b4b526bfdd27c97987b84aeae38d33068 100644 --- a/internal/agent/tools/view.go +++ b/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\n" output += getDiagnostics(filePath, lspClients) - recordFileRead(filePath) + filetracker.RecordRead(filePath) return fantasy.WithResponseMetadata( fantasy.NewTextResponse(output), ViewResponseMetadata{ diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index 82684001372ee45b1d71fa34384e6e6c7a92db25..4ffd44a0553d1a1646d20dac557ab4e1bc47f45a 100644 --- a/internal/agent/tools/write.go +++ b/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) diff --git a/internal/filetracker/filetracker.go b/internal/filetracker/filetracker.go new file mode 100644 index 0000000000000000000000000000000000000000..534a19dacdc209f7ef2d9c5b107cb5f88a665ee5 --- /dev/null +++ b/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) +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 014d662ce59d1de84f16cd17057aa158c80384a7..972824be0599fb37651f8b607a90114387a73f3c 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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), From 7766ea6a65b1a7e951ac538ba7e310d4bd303e56 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 6 Jan 2026 17:14:52 -0300 Subject: [PATCH 5/9] chore(deps): update catwalk --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9d7b5991ec5a17da4dc1c43636872b7e81a05195..8877336bd0a42158b2a96b3c34c6544d190f2151 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 25e0c713e1c3d0e7562b4f9d1ebcfb07f8872fd8..29ac00482dedaeb5f7444810b7194a367c6f67a9 100644 --- a/go.sum +++ b/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= From 6b3cd26f9f78ae7318153922763025bfa8831443 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 7 Jan 2026 03:35:46 -0700 Subject: [PATCH 6/9] feat(skills): also load from .config/agents (#1755) --- internal/config/load.go | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/internal/config/load.go b/internal/config/load.go index b16df0ee76d66e08e0a2e51862b8c5846100dafb..1747e3ba8fe94700f2ca249443926491175f6f66 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -365,10 +365,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 +747,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"), + } } From 43b0e0bcba1930653125dc156dd532e436a63b1d Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 7 Jan 2026 03:37:19 -0700 Subject: [PATCH 7/9] fix(mcp): centrally filter disabled tools (#1622) --- internal/agent/coordinator.go | 6 ------ internal/agent/tools/mcp/init.go | 4 ++-- internal/agent/tools/mcp/tools.go | 29 +++++++++++++++++++++++++---- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index b1be1be93b4b428011bfc360e548da560e087f69..a6ec70fbbd6aec71087c250ac8635fd4ffcc7159 100644 --- a/internal/agent/coordinator.go +++ b/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) diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index 6ad77bcedbf528e9c355bb0533093455ed12bcee..be27ce3f8ae5b9b7f425e496a1726bc23eaf3aae 100644 --- a/internal/agent/tools/mcp/init.go +++ b/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) diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go index 3a874aa8e1e6d790f8a9af2c9df83dfbbf49e942..779baa55d93bc54523bac81c5094bacee7fc68fb 100644 --- a/internal/agent/tools/mcp/tools.go +++ b/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 } From 5590161f86806f00bc8e4702b0c67d2abcaa9c62 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Wed, 7 Jan 2026 13:18:45 -0300 Subject: [PATCH 8/9] feat: remove claude code support (#1783) --- internal/agent/agent.go | 18 -- internal/agent/coordinator.go | 8 +- internal/cmd/login.go | 65 +---- internal/config/config.go | 41 ++- internal/config/load.go | 11 +- internal/oauth/claude/challenge.go | 28 -- internal/oauth/claude/oauth.go | 126 --------- internal/tui/components/chat/splash/splash.go | 202 +------------ .../tui/components/dialogs/claude/method.go | 115 -------- .../tui/components/dialogs/claude/oauth.go | 267 ------------------ .../tui/components/dialogs/models/keys.go | 57 ---- .../tui/components/dialogs/models/models.go | 122 -------- internal/tui/page/chat/chat.go | 52 +--- 13 files changed, 34 insertions(+), 1078 deletions(-) delete mode 100644 internal/oauth/claude/challenge.go delete mode 100644 internal/oauth/claude/oauth.go delete mode 100644 internal/tui/components/dialogs/claude/method.go delete mode 100644 internal/tui/components/dialogs/claude/oauth.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 79c8fbeecf2224712e10ddde2453459f3c3e8dc7..759a9274f2f4cc8c306ac0cc042de89cd1a25097 100644 --- a/internal/agent/agent.go +++ b/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{ diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index a6ec70fbbd6aec71087c250ac8635fd4ffcc7159..b13603bb131090c86eaff3f6ea9527cb1b9dacf6 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -518,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)) @@ -731,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: diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 0d6c910f407e63d9a52e14878769a0381779cb46..07cc90d320ebd4817474a1a14553558caca5e950 100644 --- a/internal/cmd/login.go +++ b/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() diff --git a/internal/config/config.go b/internal/config/config.go index e68ad8c27ca7e3c2313a3b18b48bcbedc3d677e9..901562420e61fe3950886bebba7ff094eb8c91b6 100644 --- a/internal/config/config.go +++ b/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" @@ -155,21 +154,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()) } @@ -522,6 +506,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) @@ -536,8 +539,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: @@ -554,8 +555,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() } @@ -594,8 +593,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() } diff --git a/internal/config/load.go b/internal/config/load.go index 1747e3ba8fe94700f2ca249443926491175f6f66..63904abc057e877c959991769c20f62a7ac8459a 100644 --- a/internal/config/load.go +++ b/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 { diff --git a/internal/oauth/claude/challenge.go b/internal/oauth/claude/challenge.go deleted file mode 100644 index ec9ed3c5d17e91fc5dc8c33f44f3d6a4ce4aa244..0000000000000000000000000000000000000000 --- a/internal/oauth/claude/challenge.go +++ /dev/null @@ -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 -} diff --git a/internal/oauth/claude/oauth.go b/internal/oauth/claude/oauth.go deleted file mode 100644 index b3c47960453385395ec2b6988229d0d6e5e3eae4..0000000000000000000000000000000000000000 --- a/internal/oauth/claude/oauth.go +++ /dev/null @@ -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) -} diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 8a053294da3e342661c0db8b38cd371103c943b1..517f6d0930c46cf3d2e9f656c22515de4e9785fd 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/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 } diff --git a/internal/tui/components/dialogs/claude/method.go b/internal/tui/components/dialogs/claude/method.go deleted file mode 100644 index 071d437799dcd2e3d5b9e60c33c7173c18577016..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/claude/method.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/claude/oauth.go b/internal/tui/components/dialogs/claude/oauth.go deleted file mode 100644 index f8da5b4fffbc75708676a1545f9a6719b7e2f198..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/claude/oauth.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go index eda235aebb858fef21c582921cfb9e305a6fed19..ff81404b1f1937fff09d917bf3a9e3b24f4d38c9 100644 --- a/internal/tui/components/dialogs/models/keys.go +++ b/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( diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index afca44ecd5e64e42e3b375311d3c5ff8efaedd5b..b06b4b475a9ababbda9e0702fc5552b0959741ba 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/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 { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index d86e60c8cdcb0f6d87b7c97a6e40e83bddffeace..9a4b69f5507fbb62b7ee93df6326f94cf79d22ad 100644 --- a/internal/tui/page/chat/chat.go +++ b/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(