Detailed changes
@@ -664,17 +664,6 @@ Or by setting the following in your config:
Crush also respects the [`DO_NOT_TRACK`](https://consoledonottrack.com)
convention which can be enabled via `export DO_NOT_TRACK=1`.
-## A Note on Claude Max and GitHub Copilot
-
-Crush only supports model providers through official, compliant APIs. We do not
-support or endorse any methods that rely on personal Claude Max and GitHub
-Copilot accounts or OAuth workarounds, which violate Anthropic and
-Microsoftβs Terms of Service.
-
-Weβre committed to building sustainable, trusted integrations with model
-providers. If youβre a provider interested in working with us,
-[reach out](mailto:vt100@charm.sh).
-
## Contributing
See the [contributing guide](https://github.com/charmbracelet/crush?tab=contributing-ov-file#contributing).
@@ -6,17 +6,17 @@ require (
charm.land/bubbles/v2 v2.0.0-rc.1
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562
charm.land/fantasy v0.3.2
- charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
+ charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca
charm.land/x/vcr v0.1.1
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/MakeNowJust/heredoc v1.0.0
- github.com/PuerkitoBio/goquery v1.10.3
+ github.com/PuerkitoBio/goquery v1.11.0
github.com/alecthomas/chroma/v2 v2.20.0
github.com/atotto/clipboard v0.1.4
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.8.2
+ github.com/charmbracelet/catwalk v0.9.4
github.com/charmbracelet/colorprofile v0.3.3
github.com/charmbracelet/fang v0.4.4
github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930
@@ -37,11 +37,12 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/modelcontextprotocol/go-sdk v1.1.0
github.com/muesli/termenv v0.16.0
- github.com/ncruces/go-sqlite3 v0.30.1
+ github.com/ncruces/go-sqlite3 v0.30.2
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/nxadm/tail v1.4.11
github.com/openai/openai-go/v2 v2.7.1
- github.com/posthog/posthog-go v1.6.12
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
+ github.com/posthog/posthog-go v1.6.13
github.com/pressly/goose/v3 v3.26.0
github.com/qjebbs/go-jsons v1.0.0-alpha.4
github.com/rivo/uniseg v0.4.7
@@ -140,7 +141,7 @@ require (
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
- github.com/tetratelabs/wazero v1.10.0 // indirect
+ github.com/tetratelabs/wazero v1.10.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@@ -159,13 +160,13 @@ require (
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
- golang.org/x/crypto v0.43.0 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.27.0 // indirect
- golang.org/x/net v0.45.0 // indirect
+ golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sys v0.38.0 // indirect
- golang.org/x/term v0.36.0 // indirect
+ golang.org/x/term v0.37.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/api v0.239.0 // indirect
google.golang.org/genai v1.34.0 // indirect
@@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562 h1:61aovinon0n
charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251124184313-5de0f1f67562/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
charm.land/fantasy v0.3.2 h1:yHTsSZ25LcICMRw3xzdz3OkaZtDQch+B5ljJo17HxgU=
charm.land/fantasy v0.3.2/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
-charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
-charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca h1:6bVc8OFotCS4sS7HKqxTudP7yn8Y0ODR6df2pdlY/+s=
+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251119143523-0334bb4562ca/go.mod h1:XSJjv7DaH4zd1Y27kZis295RkEj9OFR9zh2WffQQsKQ=
charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
@@ -29,8 +29,8 @@ github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
-github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
-github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
+github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
+github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x518wl0bCsw0t0=
github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
@@ -88,8 +88,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.8.2 h1:J7xq/ft/ZByJCHl3JpgvxlCd59bzZPugy66XuoL4vAs=
-github.com/charmbracelet/catwalk v0.8.2/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
+github.com/charmbracelet/catwalk v0.9.4 h1:et3nP3p1jfJbQE9UiN9vzfojsihgtQp2r9tvn8qEL28=
+github.com/charmbracelet/catwalk v0.9.4/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -246,8 +246,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/ncruces/go-sqlite3 v0.30.1 h1:pHC3YsyRdJv4pCMB4MO1Q2BXw/CAa+Hoj7GSaKtVk+g=
-github.com/ncruces/go-sqlite3 v0.30.1/go.mod h1:UVsWrQaq1qkcal5/vT5lOJnZCVlR5rsThKdwidjFsKc=
+github.com/ncruces/go-sqlite3 v0.30.2 h1:1GVbHAkKAOwjJd3JYl8ldrYROudfZUOah7oXPD7VZbQ=
+github.com/ncruces/go-sqlite3 v0.30.2/go.mod h1:AxKu9sRxkludimFocbktlY6LiYSkxiI5gTA8r+os/Nw=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
@@ -267,8 +267,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.6.12 h1:rsOBL/YdMfLJtOVjKJLgdzYmvaL3aIW6IVbAteSe+aI=
-github.com/posthog/posthog-go v1.6.12/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
+github.com/posthog/posthog-go v1.6.13 h1:4t9j0VOIJBgITm4v5rLsLy3IKUkU9dn2VusMNzZXScw=
+github.com/posthog/posthog-go v1.6.13/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
@@ -308,8 +308,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/tetratelabs/wazero v1.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk=
-github.com/tetratelabs/wazero v1.10.0/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
+github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
+github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -367,8 +367,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
-golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
@@ -389,8 +389,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
-golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -408,6 +408,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -430,8 +431,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
-golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -238,8 +238,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
}
}
- if a.systemPromptPrefix != "" {
- prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(a.systemPromptPrefix)}, prepared.Messages...)
+ if promptPrefix := a.promptPrefix(); promptPrefix != "" {
+ prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
}
var assistantMsg message.Message
@@ -789,6 +789,10 @@ 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 {
@@ -882,3 +886,16 @@ func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
func (a *sessionAgent) Model() Model {
return a.largeModel
}
+
+func (a *sessionAgent) promptPrefix() string {
+ if a.isClaudeCode() {
+ return "You are Claude Code, Anthropic's official CLI for Claude."
+ }
+ return a.systemPromptPrefix
+}
+
+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
+}
@@ -1,6 +1,7 @@
package config
import (
+ "cmp"
"context"
"fmt"
"log/slog"
@@ -14,6 +15,7 @@ import (
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/env"
+ "github.com/charmbracelet/crush/internal/oauth"
"github.com/invopop/jsonschema"
"github.com/tidwall/sjson"
)
@@ -92,6 +94,8 @@ type ProviderConfig struct {
Type catwalk.Type `json:"type,omitempty" jsonschema:"description=Provider type that determines the API format,enum=openai,enum=openai-compat,enum=anthropic,enum=gemini,enum=azure,enum=vertexai,default=openai"`
// The provider's API key.
APIKey string `json:"api_key,omitempty" jsonschema:"description=API key for authentication with the provider,example=$OPENAI_API_KEY"`
+ // OAuthToken for providers that use OAuth2 authentication.
+ OAuthToken *oauth.Token `json:"oauth,omitempty" jsonschema:"description=OAuth2 token for authentication with the provider"`
// Marks the provider as disabled.
Disable bool `json:"disable,omitempty" jsonschema:"description=Whether this provider is disabled,default=false"`
@@ -112,6 +116,24 @@ type ProviderConfig struct {
Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
}
+func (pc *ProviderConfig) SetupClaudeCode() {
+ if !strings.HasPrefix(pc.APIKey, "Bearer ") {
+ pc.APIKey = fmt.Sprintf("Bearer %s", pc.APIKey)
+ }
+ 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
+}
+
type MCPType string
const (
@@ -448,16 +470,34 @@ func (c *Config) SetConfigField(key string, value any) error {
return nil
}
-func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
- // First save to the config file
- err := c.SetConfigField("providers."+providerID+".api_key", apiKey)
- if err != nil {
- return fmt.Errorf("failed to save API key to config file: %w", err)
+func (c *Config) SetProviderAPIKey(providerID string, apiKey any) error {
+ var providerConfig ProviderConfig
+ var exists bool
+ var setKeyOrToken func()
+
+ switch v := apiKey.(type) {
+ case string:
+ if err := c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
+ return fmt.Errorf("failed to save api key to config file: %w", err)
+ }
+ setKeyOrToken = func() { providerConfig.APIKey = v }
+ case *oauth.Token:
+ if err := cmp.Or(
+ c.SetConfigField(fmt.Sprintf("providers.%s.api_key", providerID), v.AccessToken),
+ c.SetConfigField(fmt.Sprintf("providers.%s.oauth", providerID), v),
+ ); err != nil {
+ return err
+ }
+ setKeyOrToken = func() {
+ providerConfig.APIKey = v.AccessToken
+ providerConfig.OAuthToken = v
+ providerConfig.SetupClaudeCode()
+ }
}
- providerConfig, exists := c.Providers.Get(providerID)
+ providerConfig, exists = c.Providers.Get(providerID)
if exists {
- providerConfig.APIKey = apiKey
+ setKeyOrToken()
c.Providers.Set(providerID, providerConfig)
return nil
}
@@ -477,12 +517,12 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
Name: foundProvider.Name,
BaseURL: foundProvider.APIEndpoint,
Type: foundProvider.Type,
- APIKey: apiKey,
Disable: false,
ExtraHeaders: make(map[string]string),
ExtraParams: make(map[string]string),
Models: foundProvider.Models,
}
+ setKeyOrToken()
} else {
return fmt.Errorf("provider with ID %s not found in known providers", providerID)
}
@@ -1,6 +1,7 @@
package config
import (
+ "cmp"
"context"
"encoding/json"
"fmt"
@@ -18,9 +19,11 @@ import (
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/env"
+ "github.com/charmbracelet/crush/internal/event"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/home"
"github.com/charmbracelet/crush/internal/log"
+ "github.com/charmbracelet/crush/internal/oauth/claude"
powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
)
@@ -133,6 +136,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
knownProviderNames := make(map[string]bool)
restore := PushPopCrushEnv()
defer restore()
+
for _, p := range knownProviders {
knownProviderNames[string(p.ID)] = true
config, configExists := c.Providers.Get(string(p.ID))
@@ -185,6 +189,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
Name: p.Name,
BaseURL: p.APIEndpoint,
APIKey: p.APIKey,
+ OAuthToken: config.OAuthToken,
Type: p.Type,
Disable: config.Disable,
SystemPromptPrefix: config.SystemPromptPrefix,
@@ -194,6 +199,29 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
Models: p.Models,
}
+ if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil {
+ if config.OAuthToken.IsExpired() {
+ newToken, err := claude.RefreshToken(context.TODO(), config.OAuthToken.RefreshToken)
+ if err == nil {
+ slog.Info("Successfully refreshed Anthropic OAuth token")
+ config.OAuthToken = newToken
+ prepared.OAuthToken = newToken
+ if err := cmp.Or(
+ c.SetConfigField("providers.anthropic.api_key", newToken.AccessToken),
+ c.SetConfigField("providers.anthropic.oauth", newToken),
+ ); err != nil {
+ return err
+ }
+ } else {
+ slog.Error("Failed to refresh Anthropic OAuth token", "error", err)
+ event.Error(err)
+ }
+ } else {
+ slog.Info("Using existing non-expired Anthropic OAuth token")
+ }
+ prepared.SetupClaudeCode()
+ }
+
switch p.ID {
// Handle specific providers that require additional configuration
case catwalk.InferenceProviderVertexAI:
@@ -0,0 +1,28 @@
+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
+}
@@ -0,0 +1,126 @@
+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)
+}
@@ -0,0 +1,23 @@
+package oauth
+
+import (
+ "time"
+)
+
+// Token represents an OAuth2 token from Claude Code Max.
+type Token struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int `json:"expires_in"`
+ ExpiresAt int64 `json:"expires_at"`
+}
+
+// SetExpiresAt calculates and sets the ExpiresAt field based on the current time and ExpiresIn.
+func (t *Token) SetExpiresAt() {
+ t.ExpiresAt = time.Now().Add(time.Duration(t.ExpiresIn) * time.Second).Unix()
+}
+
+// IsExpired checks if the token is expired or about to expire (within 10% of its lifetime).
+func (t *Token) IsExpired() bool {
+ return time.Now().Unix() >= (t.ExpiresAt - int64(t.ExpiresIn)/10)
+}
@@ -12,7 +12,8 @@ type KeyMap struct {
No,
Tab,
LeftRight,
- Back key.Binding
+ Back,
+ Copy key.Binding
}
func DefaultKeyMap() KeyMap {
@@ -49,5 +50,9 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("esc", "alt+esc"),
key.WithHelp("esc", "back"),
),
+ Copy: key.NewBinding(
+ key.WithKeys("c"),
+ key.WithHelp("c", "copy url"),
+ ),
}
}
@@ -9,6 +9,7 @@ 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"
"github.com/charmbracelet/crush/internal/config"
@@ -16,6 +17,7 @@ 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/models"
"github.com/charmbracelet/crush/internal/tui/components/logo"
lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
@@ -41,6 +43,18 @@ 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
}
const (
@@ -72,6 +86,12 @@ type splashCmp struct {
selectedModel *models.ModelOption
isAPIKeyValid bool
apiKeyValue string
+
+ // Claude state
+ claudeAuthMethodChooser *claude.AuthMethodChooser
+ claudeOAuth2 *claude.OAuth2
+ showClaudeAuthMethodChooser bool
+ showClaudeOAuth2 bool
}
func New() Splash {
@@ -97,6 +117,9 @@ func New() Splash {
modelList: modelList,
apiKeyInput: apiKeyInput,
selectedNo: false,
+
+ claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
+ claudeOAuth2: claude.NewOAuth2(),
}
}
@@ -115,7 +138,12 @@ func (s *splashCmp) GetSize() (int, int) {
// Init implements SplashPage.
func (s *splashCmp) Init() tea.Cmd {
- return tea.Batch(s.modelList.Init(), s.apiKeyInput.Init())
+ return tea.Batch(
+ s.modelList.Init(),
+ s.apiKeyInput.Init(),
+ s.claudeAuthMethodChooser.Init(),
+ s.claudeOAuth2.Init(),
+ )
}
// SetSize implements SplashPage.
@@ -131,6 +159,7 @@ 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)
}
@@ -139,6 +168,28 @@ 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 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)
@@ -150,16 +201,48 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return s, cmd
case SubmitAPIKeyMsg:
if s.isAPIKeyValid {
- return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
}
case tea.KeyPressMsg:
switch {
+ case key.Matches(msg, s.keyMap.Copy):
+ if 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"),
+ )
+ } else if s.showClaudeAuthMethodChooser {
+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
+ return s, cmd
+ } else if s.showClaudeOAuth2 {
+ u, cmd := s.claudeOAuth2.Update(msg)
+ s.claudeOAuth2 = u.(*claude.OAuth2)
+ return s, cmd
+ }
case key.Matches(msg, s.keyMap.Back):
+ if s.showClaudeAuthMethodChooser {
+ s.claudeAuthMethodChooser.SetDefaults()
+ s.showClaudeAuthMethodChooser = false
+ return s, nil
+ }
+ if s.showClaudeOAuth2 {
+ s.claudeOAuth2.SetDefaults()
+ s.showClaudeOAuth2 = false
+ s.showClaudeAuthMethodChooser = true
+ return s, nil
+ }
if s.isAPIKeyValid {
return s, nil
}
if s.needsAPIKey {
- // Go back to model selection
+ if s.selectedModel.Provider.ID == catwalk.InferenceProviderAnthropic {
+ s.showClaudeAuthMethodChooser = true
+ }
s.needsAPIKey = false
s.selectedModel = nil
s.isAPIKeyValid = false
@@ -168,8 +251,32 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return s, nil
}
case key.Matches(msg, s.keyMap.Select):
+ if 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
+ }
+ if s.showClaudeOAuth2 {
+ m2, cmd2 := s.claudeOAuth2.ValidationConfirm()
+ s.claudeOAuth2 = m2.(*claude.OAuth2)
+ return s, cmd2
+ }
if s.isAPIKeyValid {
- return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
}
if s.isOnboarding && !s.needsAPIKey {
selectedItem := s.modelList.SelectedModel()
@@ -181,6 +288,10 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
s.isOnboarding = false
return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
} else {
+ if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
+ s.showClaudeAuthMethodChooser = true
+ return s, nil
+ }
// Provider not configured, show API key input
s.needsAPIKey = true
s.selectedModel = selectedItem
@@ -232,6 +343,10 @@ 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)
@@ -272,7 +387,15 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return s, s.initializeProject()
}
default:
- if s.needsAPIKey {
+ if s.showClaudeAuthMethodChooser {
+ u, cmd := s.claudeAuthMethodChooser.Update(msg)
+ s.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
+ return s, cmd
+ } else if s.showClaudeOAuth2 {
+ u, cmd := s.claudeOAuth2.Update(msg)
+ s.claudeOAuth2 = u.(*claude.OAuth2)
+ return s, cmd
+ } else if s.needsAPIKey {
u, cmd := s.apiKeyInput.Update(msg)
s.apiKeyInput = u.(*models.APIKeyInput)
return s, cmd
@@ -283,7 +406,11 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
}
case tea.PasteMsg:
- if s.needsAPIKey {
+ if s.showClaudeOAuth2 {
+ u, cmd := s.claudeOAuth2.Update(msg)
+ s.claudeOAuth2 = u.(*claude.OAuth2)
+ return s, cmd
+ } else if s.needsAPIKey {
u, cmd := s.apiKeyInput.Update(msg)
s.apiKeyInput = u.(*models.APIKeyInput)
return s, cmd
@@ -293,14 +420,20 @@ func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return s, cmd
}
case spinner.TickMsg:
- u, cmd := s.apiKeyInput.Update(msg)
- s.apiKeyInput = u.(*models.APIKeyInput)
- return s, cmd
+ if s.showClaudeOAuth2 {
+ u, cmd := s.claudeOAuth2.Update(msg)
+ s.claudeOAuth2 = u.(*claude.OAuth2)
+ return s, cmd
+ } else {
+ u, cmd := s.apiKeyInput.Update(msg)
+ s.apiKeyInput = u.(*models.APIKeyInput)
+ return s, cmd
+ }
}
return s, nil
}
-func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
+func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
if s.selectedModel == nil {
return nil
}
@@ -318,7 +451,10 @@ func (s *splashCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
s.selectedModel = nil
s.isAPIKeyValid = false
- return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
+ if close {
+ return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
+ }
+ return cmd
}
func (s *splashCmp) initializeProject() tea.Cmd {
@@ -426,7 +562,39 @@ func (s *splashCmp) isProviderConfigured(providerID string) bool {
func (s *splashCmp) View() string {
t := styles.CurrentTheme()
var content string
- if s.needsAPIKey {
+ if s.showClaudeAuthMethodChooser {
+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
+ 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,
+ )
+ } else if s.showClaudeOAuth2 {
+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
+ 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,
+ )
+ } else if s.needsAPIKey {
remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
@@ -524,6 +692,16 @@ func (s *splashCmp) View() string {
}
func (s *splashCmp) Cursor() *tea.Cursor {
+ if s.showClaudeAuthMethodChooser {
+ return nil
+ }
+ if 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
+ }
if s.needsAPIKey {
cursor := s.apiKeyInput.Cursor()
if cursor != nil {
@@ -596,17 +774,23 @@ 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 {
+ if s.needsAPIKey || s.showClaudeOAuth2 {
+ var view string
+ if s.needsAPIKey {
+ view = s.apiKeyInput.View()
+ } else {
+ view = s.claudeOAuth2.View()
+ }
infoSectionHeight := lipgloss.Height(s.infoSection())
baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
- remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
+ remainingHeight := s.height - baseOffset - lipgloss.Height(view) - SplashScreenPaddingY
offset := baseOffset + remainingHeight
cursor.Y += offset
- cursor.X = cursor.X + 1
+ cursor.X += 1
} else if s.isOnboarding {
offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
cursor.Y += offset
- cursor.X = cursor.X + 1
+ cursor.X += 1
}
return cursor
@@ -621,7 +805,21 @@ func (s *splashCmp) logoGap() int {
// Bindings implements SplashPage.
func (s *splashCmp) Bindings() []key.Binding {
- if s.needsAPIKey {
+ if s.showClaudeAuthMethodChooser {
+ return []key.Binding{
+ s.keyMap.Select,
+ s.keyMap.Tab,
+ s.keyMap.Back,
+ }
+ } else if s.showClaudeOAuth2 {
+ bindings := []key.Binding{
+ s.keyMap.Select,
+ }
+ if s.claudeOAuth2.State == claude.OAuthStateURL {
+ bindings = append(bindings, s.keyMap.Copy)
+ }
+ return bindings
+ } else if s.needsAPIKey {
return []key.Binding{
s.keyMap.Select,
s.keyMap.Back,
@@ -726,3 +924,19 @@ func (s *splashCmp) IsShowingAPIKey() bool {
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
+}
@@ -0,0 +1,115 @@
+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
+}
@@ -0,0 +1,267 @@
+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
+}
@@ -8,11 +8,17 @@ type KeyMap struct {
Select,
Next,
Previous,
+ Choose,
Tab,
Close key.Binding
isAPIKeyHelp bool
isAPIKeyValid bool
+
+ isClaudeAuthChoiseHelp bool
+ isClaudeOAuthHelp bool
+ isClaudeOAuthURLState bool
+ isClaudeOAuthHelpComplete bool
}
func DefaultKeyMap() KeyMap {
@@ -29,6 +35,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("up", "ctrl+p"),
key.WithHelp("β", "previous item"),
),
+ Choose: key.NewBinding(
+ key.WithKeys("left", "right", "h", "l"),
+ key.WithHelp("ββ", "choose"),
+ ),
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "toggle type"),
@@ -64,8 +74,64 @@ func (k KeyMap) FullHelp() [][]key.Binding {
// ShortHelp implements help.KeyMap.
func (k KeyMap) ShortHelp() []key.Binding {
+ if k.isClaudeAuthChoiseHelp {
+ 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(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "submit"),
+ ),
k.Close,
}
} else if k.isAPIKeyValid {
@@ -9,10 +9,12 @@ 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/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/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
@@ -67,6 +69,12 @@ type modelDialogCmp struct {
selectedModelType config.SelectedModelType
isAPIKeyValid bool
apiKeyValue string
+
+ // Claude state
+ claudeAuthMethodChooser *claude.AuthMethodChooser
+ claudeOAuth2 *claude.OAuth2
+ showClaudeAuthMethodChooser bool
+ showClaudeOAuth2 bool
}
func NewModelDialogCmp() ModelDialog {
@@ -91,11 +99,19 @@ func NewModelDialogCmp() ModelDialog {
width: defaultWidth,
keyMap: DefaultKeyMap(),
help: help,
+
+ claudeAuthMethodChooser: claude.NewAuthMethodChooser(),
+ claudeOAuth2: claude.NewOAuth2(),
}
}
func (m *modelDialogCmp) Init() tea.Cmd {
- return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
+ return tea.Batch(
+ m.modelList.Init(),
+ m.apiKeyInput.Init(),
+ m.claudeAuthMethodChooser.Init(),
+ m.claudeOAuth2.Init(),
+ )
}
func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
@@ -105,16 +121,84 @@ 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)
m.apiKeyInput = u.(*APIKeyInput)
return m, cmd
+ 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.saveAPIKeyAndContinue(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 {
+ case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))):
+ if 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):
+ if m.showClaudeAuthMethodChooser {
+ m.claudeAuthMethodChooser.ToggleChoice()
+ return m, nil
+ }
case key.Matches(msg, m.keyMap.Select):
+ selectedItem := m.modelList.SelectedModel()
+
+ modelType := config.SelectedModelTypeLarge
+ if m.modelList.GetModelType() == SmallModelType {
+ modelType = config.SelectedModelTypeSmall
+ }
+
+ askForApiKey := func() {
+ m.keyMap.isClaudeAuthChoiseHelp = false
+ m.keyMap.isClaudeOAuthHelp = false
+ m.keyMap.isAPIKeyHelp = true
+ 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.isClaudeAuthChoiseHelp = 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.saveAPIKeyAndContinue(m.apiKeyValue)
+ return m, m.saveAPIKeyAndContinue(m.apiKeyValue, true)
}
if m.needsAPIKey {
// Handle API key submission
@@ -154,15 +238,6 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
},
)
}
- // Normal model selection
- selectedItem := m.modelList.SelectedModel()
-
- var modelType config.SelectedModelType
- if m.modelList.GetModelType() == LargeModelType {
- modelType = config.SelectedModelTypeLarge
- } else {
- modelType = config.SelectedModelTypeSmall
- }
// Check if provider is configured
if m.isProviderConfigured(string(selectedItem.Provider.ID)) {
@@ -179,27 +254,38 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}),
)
} else {
- // Provider not configured, show API key input
- m.needsAPIKey = true
- m.selectedModel = selectedItem
- m.selectedModelType = modelType
- m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
+ if selectedItem.Provider.ID == catwalk.InferenceProviderAnthropic {
+ m.showClaudeAuthMethodChooser = true
+ m.keyMap.isClaudeAuthChoiseHelp = true
+ return m, nil
+ }
+ askForApiKey()
return m, nil
}
case key.Matches(msg, m.keyMap.Tab):
- if m.needsAPIKey {
+ switch {
+ case m.showClaudeAuthMethodChooser:
+ m.claudeAuthMethodChooser.ToggleChoice()
+ return m, nil
+ case m.needsAPIKey:
u, cmd := m.apiKeyInput.Update(msg)
m.apiKeyInput = u.(*APIKeyInput)
return m, cmd
- }
- if m.modelList.GetModelType() == LargeModelType {
+ case m.modelList.GetModelType() == LargeModelType:
m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
return m, m.modelList.SetModelType(SmallModelType)
- } else {
+ default:
m.modelList.SetInputPlaceholder(largeModelInputPlaceholder)
return m, m.modelList.SetModelType(LargeModelType)
}
case key.Matches(msg, m.keyMap.Close):
+ if m.showClaudeAuthMethodChooser {
+ m.claudeAuthMethodChooser.SetDefaults()
+ m.showClaudeAuthMethodChooser = false
+ m.keyMap.isClaudeAuthChoiseHelp = false
+ m.keyMap.isClaudeOAuthHelp = false
+ return m, nil
+ }
if m.needsAPIKey {
if m.isAPIKeyValid {
return m, nil
@@ -214,7 +300,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
return m, util.CmdHandler(dialogs.CloseDialogMsg{})
default:
- if m.needsAPIKey {
+ if m.showClaudeAuthMethodChooser {
+ u, cmd := m.claudeAuthMethodChooser.Update(msg)
+ m.claudeAuthMethodChooser = u.(*claude.AuthMethodChooser)
+ return m, cmd
+ } else if m.showClaudeOAuth2 {
+ u, cmd := m.claudeOAuth2.Update(msg)
+ m.claudeOAuth2 = u.(*claude.OAuth2)
+ return m, cmd
+ } else if m.needsAPIKey {
u, cmd := m.apiKeyInput.Update(msg)
m.apiKeyInput = u.(*APIKeyInput)
return m, cmd
@@ -225,7 +319,11 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
}
}
case tea.PasteMsg:
- if m.needsAPIKey {
+ if m.showClaudeOAuth2 {
+ u, cmd := m.claudeOAuth2.Update(msg)
+ m.claudeOAuth2 = u.(*claude.OAuth2)
+ return m, cmd
+ } else if m.needsAPIKey {
u, cmd := m.apiKeyInput.Update(msg)
m.apiKeyInput = u.(*APIKeyInput)
return m, cmd
@@ -235,9 +333,15 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
return m, cmd
}
case spinner.TickMsg:
- u, cmd := m.apiKeyInput.Update(msg)
- m.apiKeyInput = u.(*APIKeyInput)
- return m, cmd
+ if m.showClaudeOAuth2 {
+ u, cmd := m.claudeOAuth2.Update(msg)
+ m.claudeOAuth2 = u.(*claude.OAuth2)
+ return m, cmd
+ } else {
+ u, cmd := m.apiKeyInput.Update(msg)
+ m.apiKeyInput = u.(*APIKeyInput)
+ return m, cmd
+ }
}
return m, nil
}
@@ -245,7 +349,29 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
func (m *modelDialogCmp) View() string {
t := styles.CurrentTheme()
- if m.needsAPIKey {
+ 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
m.keyMap.isAPIKeyValid = m.isAPIKeyValid
@@ -275,6 +401,16 @@ func (m *modelDialogCmp) View() string {
}
func (m *modelDialogCmp) Cursor() *tea.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 {
@@ -365,7 +501,7 @@ func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*cat
return nil, nil
}
-func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
+func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
if m.selectedModel == nil {
return util.ReportError(fmt.Errorf("no model selected"))
}
@@ -378,8 +514,12 @@ func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
// Reset API key state and continue with model selection
selectedModel := *m.selectedModel
- return tea.Sequence(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
+ var cmds []tea.Cmd
+ if close {
+ cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
+ }
+ cmds = append(
+ cmds,
util.CmdHandler(ModelSelectedMsg{
Model: config.SelectedModel{
Model: selectedModel.Model.ID,
@@ -390,4 +530,5 @@ func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
ModelType: m.selectedModelType,
}),
)
+ return tea.Sequence(cmds...)
}
@@ -29,6 +29,7 @@ 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/filepicker"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
@@ -293,6 +294,13 @@ 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:
+ if p.focusedPane == PanelTypeSplash {
+ u, cmd := p.splash.Update(msg)
+ p.splash = u.(splash.Splash)
+ cmds = append(cmds, cmd)
+ }
+ return p, tea.Batch(cmds...)
case models.APIKeyStateChangeMsg:
if p.focusedPane == PanelTypeSplash {
u, cmd := p.splash.Update(msg)
@@ -816,6 +824,71 @@ 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():
+ if 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"),
+ ),
+ )
+ } else if p.splash.IsClaudeOAuthComplete() {
+ shortList = append(shortList,
+ key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "continue"),
+ ),
+ )
+ } else {
+ shortList = append(shortList,
+ key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "submit"),
+ ),
+ )
+ }
+ shortList = append(shortList,
+ // 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.IsShowingAPIKey():
shortList = append(shortList,
// Choose model
@@ -67,10 +67,17 @@ func NewCharmtoneTheme() *Theme {
t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
+ // Editor: Yolo Mode.
t.YoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
t.YoloIconBlurred = t.YoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
t.YoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::")
t.YoloDotsBlurred = t.YoloDotsFocused.Foreground(charmtone.Squid)
+ // oAuth Chooser.
+ t.AuthBorderSelected = lipgloss.NewStyle().BorderForeground(charmtone.Guac)
+ t.AuthTextSelected = lipgloss.NewStyle().Foreground(charmtone.Julep)
+ t.AuthBorderUnselected = lipgloss.NewStyle().BorderForeground(charmtone.Iron)
+ t.AuthTextUnselected = lipgloss.NewStyle().Foreground(charmtone.Squid)
+
return t
}
@@ -85,12 +85,18 @@ type Theme struct {
ItemErrorIcon lipgloss.Style
ItemOnlineIcon lipgloss.Style
- // Editor: Yolo Mode
+ // Editor: Yolo Mode.
YoloIconFocused lipgloss.Style
YoloIconBlurred lipgloss.Style
YoloDotsFocused lipgloss.Style
YoloDotsBlurred lipgloss.Style
+ // oAuth Chooser.
+ AuthBorderSelected lipgloss.Style
+ AuthTextSelected lipgloss.Style
+ AuthBorderUnselected lipgloss.Style
+ AuthTextUnselected lipgloss.Style
+
styles *Styles
}
@@ -484,6 +484,10 @@
"$OPENAI_API_KEY"
]
},
+ "oauth": {
+ "$ref": "#/$defs/Token",
+ "description": "OAuth2 token for authentication with the provider"
+ },
"disable": {
"type": "boolean",
"description": "Whether this provider is disabled",
@@ -625,6 +629,30 @@
"completions"
]
},
+ "Token": {
+ "properties": {
+ "access_token": {
+ "type": "string"
+ },
+ "refresh_token": {
+ "type": "string"
+ },
+ "expires_in": {
+ "type": "integer"
+ },
+ "expires_at": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "access_token",
+ "refresh_token",
+ "expires_in",
+ "expires_at"
+ ]
+ },
"ToolLs": {
"properties": {
"max_depth": {