detached 1390fea

Restore Anthropic manual OAuth as a separate login choice

commit
1390feae213a1c1a207683c8d9fab90d9d83c888
author
patriceckhart · · 2026-09-26 10:50 UTC
parent
852dd3a
signature
unsigned
4 files · +143 -28
M README.md +2 -3
@@ -115,9 +115,8 @@ Run `zot` and type `/login`. Pick one of two methods:
115115
116116 - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly.
117117 - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.
118- - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.
119- - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
120- - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
118+ - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
119+ - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. Manual code entry is not supported for its loopback redirect, so the browser must reach zot's callback port. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
121120 - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers.
122121 - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API.
123122 - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand.
@@ -115,9 +115,8 @@ Run `zot` and type `/login`. Pick one of two methods:
115 115
116 - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly.116 - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly.
117 - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.117 - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.
118- - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.118+ - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
119- - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.119+ - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. Manual code entry is not supported for its loopback redirect, so the browser must reach zot's callback port. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
120- - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
121 - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers.120 - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers.
122 - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API.121 - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API.
123 - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand.122 - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand.
M packages/agent/modes/interactive.go +1 -12
@@ -5341,17 +5341,6 @@ func (i *Interactive) startOAuthFlow(provider string) {
53415341 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
53425342 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
53435343 }
5344- // Device-code providers already support headless login and must only
5345- // start one polling flow.
5346- if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
5347- loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
5348- if err != nil {
5349- i.dialog.ShowResult(false, err.Error())
5350- return
5351- }
5352- i.dialog.ShowWaiting(loginURL)
5353- return
5354- }
53555344 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
53565345 if err != nil {
53575346 i.dialog.ShowResult(false, err.Error())
@@ -5370,7 +5359,7 @@ func (i *Interactive) startManualOAuthFlow(provider string) {
53705359 i.dialog.ShowResult(false, err.Error())
53715360 return
53725361 }
5373- i.dialog.url = url
5362+ i.dialog.ShowPasteCode(url)
53745363 i.invalidate()
53755364 }
53765365
@@ -5341,17 +5341,6 @@ func (i *Interactive) startOAuthFlow(provider string) {
5341 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {5341 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5342 _ = i.cfg.SetKimiCLIFallbackDisabled(false)5342 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
5343 }5343 }
5344- // Device-code providers already support headless login and must only
5345- // start one polling flow.
5346- if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
5347- loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
5348- if err != nil {
5349- i.dialog.ShowResult(false, err.Error())
5350- return
5351- }
5352- i.dialog.ShowWaiting(loginURL)
5353- return
5354- }
5355 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)5344 loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
5356 if err != nil {5345 if err != nil {
5357 i.dialog.ShowResult(false, err.Error())5346 i.dialog.ShowResult(false, err.Error())
@@ -5370,7 +5359,7 @@ func (i *Interactive) startManualOAuthFlow(provider string) {
5370 i.dialog.ShowResult(false, err.Error())5359 i.dialog.ShowResult(false, err.Error())
5371 return5360 return
5372 }5361 }
5373- i.dialog.url = url5362+ i.dialog.ShowPasteCode(url)
5374 i.invalidate()5363 i.invalidate()
5375 }5364 }
5376 5365
M packages/agent/modes/login_dialog.go +62 -12
@@ -18,15 +18,16 @@ type loginStep int
1818 // dialog must default to closed so nothing shows up until Open() is
1919 // explicitly called.
2020 const (
21- loginStepClosed loginStep = iota
22- loginStepMethod // pick apikey vs subscription
23- loginStepProvider // pick anthropic vs openai vs kimi
24- loginStepLlamaURL // enter and validate llama.cpp router URL
25- loginStepLlamaKey // enter optional llama.cpp API key
26- loginStepWaiting // browser open, waiting for callback
27- loginStepPasteCode // user pastes the auth code here
28- loginStepInfo // informational setup guidance
29- loginStepDone // success or error, waiting for key to dismiss
21+ loginStepClosed loginStep = iota
22+ loginStepMethod // pick apikey vs subscription
23+ loginStepProvider // pick anthropic vs openai vs kimi
24+ loginStepOAuthMethod // pick browser or copy-code for Anthropic
25+ loginStepLlamaURL // enter and validate llama.cpp router URL
26+ loginStepLlamaKey // enter optional llama.cpp API key
27+ loginStepWaiting // browser open, waiting for callback
28+ loginStepPasteCode // user pastes the auth code here
29+ loginStepInfo // informational setup guidance
30+ loginStepDone // success or error, waiting for key to dismiss
3031 )
3132
3233 const loginProviderPageSize = 8
@@ -79,6 +80,7 @@ func (d *loginDialog) Open(zotHome string) {
7980 d.url = ""
8081 d.cursor = 0
8182 d.providerQuery = ""
83+ d.codeEd = nil
8284 d.llamaEd = nil
8385 d.llamaURL = ""
8486 d.infoTitle = ""
@@ -183,6 +185,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
183185 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
184186 }
185187 lines = append(lines, frameRule(th, width))
188+ case loginStepOAuthMethod:
189+ lines = append(lines, frameHeader(th, "login - subscription - "+providerLabel(d.provider), width))
190+ lines = append(lines, th.FG256(th.Muted, "choose sign-in flow (↑/↓, enter, esc to go back):"))
191+ for idx, label := range []string{"browser callback", "copy code (headless / SSH)"} {
192+ plain := " " + label
193+ if idx == d.cursor {
194+ lines = append(lines, th.PadHighlight(plain, width))
195+ } else {
196+ lines = append(lines, th.FG256(th.Muted, plain))
197+ }
198+ }
199+ lines = append(lines, frameRule(th, width))
186200 case loginStepLlamaURL:
187201 lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width))
188202 lines = append(lines, th.FG256(th.Muted, "server URL:"))
@@ -216,7 +230,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
216230 lines = append(lines, th.FG256(th.Accent, seg))
217231 }
218232 lines = append(lines, "")
219- if d.provider == "openai-codex" || d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {
233+ if d.method == "oauth" {
220234 lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))
221235 lines = append(lines, frameRule(th, width))
222236 break
@@ -447,6 +461,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
447461 return d.handleMethodKey(k)
448462 case loginStepProvider:
449463 return d.handleProviderKey(k)
464+ case loginStepOAuthMethod:
465+ return d.handleOAuthMethodKey(k)
450466 case loginStepLlamaURL, loginStepLlamaKey:
451467 return d.handleLlamaKey(k)
452468 case loginStepWaiting:
@@ -543,6 +559,11 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
543559 d.message = ""
544560 return loginDialogAction{}
545561 }
562+ if d.method == "oauth" && d.provider == "anthropic" {
563+ d.step = loginStepOAuthMethod
564+ d.cursor = 0
565+ return loginDialogAction{}
566+ }
546567 d.step = loginStepWaiting
547568 if d.method == "apikey" {
548569 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
@@ -552,6 +573,26 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
552573 return loginDialogAction{}
553574 }
554575
576+func (d *loginDialog) handleOAuthMethodKey(k tui.Key) loginDialogAction {
577+ switch k.Kind {
578+ case tui.KeyUp:
579+ d.cursor = 0
580+ case tui.KeyDown:
581+ d.cursor = 1
582+ case tui.KeyEsc:
583+ d.step = loginStepProvider
584+ d.cursor = 0
585+ case tui.KeyEnter:
586+ if d.cursor == 1 {
587+ d.step = loginStepPasteCode
588+ return loginDialogAction{StartManual: true, Provider: d.provider}
589+ }
590+ d.step = loginStepWaiting
591+ return loginDialogAction{StartOAuth: true, Provider: d.provider}
592+ }
593+ return loginDialogAction{}
594+}
595+
555596 func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction {
556597 if k.Kind == tui.KeyEsc {
557598 d.Close()
@@ -597,6 +638,15 @@ func (d *loginDialog) ShowWaiting(url string) {
597638 d.url = url
598639 }
599640
641+// ShowPasteCode displays the URL for a manual OAuth transaction.
642+func (d *loginDialog) ShowPasteCode(url string) {
643+ if d.step == loginStepClosed {
644+ return
645+ }
646+ d.step = loginStepPasteCode
647+ d.url = url
648+}
649+
600650 // ShowInfo transitions to an informational setup dialog.
601651 // No-op if the user has already dismissed the dialog.
602652 func (d *loginDialog) ShowInfo(title string, lines []string) {
@@ -624,7 +674,7 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
624674 d.Close()
625675 return loginDialogAction{Close: true}
626676 }
627- if d.codeEd == nil {
677+ if d.method == "oauth" || d.codeEd == nil {
628678 return loginDialogAction{}
629679 }
630680 if submit := d.codeEd.HandleKey(k); submit {
@@ -667,7 +717,7 @@ func (d *loginDialog) CursorPos(width int) (row, col int) {
667717 }
668718 return baseOffset + eRow, eCol
669719 }
670- if d.codeEd == nil {
720+ if d.codeEd == nil || (d.step == loginStepWaiting && d.method == "oauth") {
671721 return -1, -1
672722 }
673723 if d.step != loginStepPasteCode && d.step != loginStepWaiting {
@@ -18,15 +18,16 @@ type loginStep int
18 // dialog must default to closed so nothing shows up until Open() is18 // dialog must default to closed so nothing shows up until Open() is
19 // explicitly called.19 // explicitly called.
20 const (20 const (
21- loginStepClosed loginStep = iota21+ loginStepClosed loginStep = iota
22- loginStepMethod // pick apikey vs subscription22+ loginStepMethod // pick apikey vs subscription
23- loginStepProvider // pick anthropic vs openai vs kimi23+ loginStepProvider // pick anthropic vs openai vs kimi
24- loginStepLlamaURL // enter and validate llama.cpp router URL24+ loginStepOAuthMethod // pick browser or copy-code for Anthropic
25- loginStepLlamaKey // enter optional llama.cpp API key25+ loginStepLlamaURL // enter and validate llama.cpp router URL
26- loginStepWaiting // browser open, waiting for callback26+ loginStepLlamaKey // enter optional llama.cpp API key
27- loginStepPasteCode // user pastes the auth code here27+ loginStepWaiting // browser open, waiting for callback
28- loginStepInfo // informational setup guidance28+ loginStepPasteCode // user pastes the auth code here
29- loginStepDone // success or error, waiting for key to dismiss29+ loginStepInfo // informational setup guidance
30+ loginStepDone // success or error, waiting for key to dismiss
30 )31 )
31 32
32 const loginProviderPageSize = 833 const loginProviderPageSize = 8
@@ -79,6 +80,7 @@ func (d *loginDialog) Open(zotHome string) {
79 d.url = ""80 d.url = ""
80 d.cursor = 081 d.cursor = 0
81 d.providerQuery = ""82 d.providerQuery = ""
83+ d.codeEd = nil
82 d.llamaEd = nil84 d.llamaEd = nil
83 d.llamaURL = ""85 d.llamaURL = ""
84 d.infoTitle = ""86 d.infoTitle = ""
@@ -183,6 +185,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
183 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))185 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
184 }186 }
185 lines = append(lines, frameRule(th, width))187 lines = append(lines, frameRule(th, width))
188+ case loginStepOAuthMethod:
189+ lines = append(lines, frameHeader(th, "login - subscription - "+providerLabel(d.provider), width))
190+ lines = append(lines, th.FG256(th.Muted, "choose sign-in flow (↑/↓, enter, esc to go back):"))
191+ for idx, label := range []string{"browser callback", "copy code (headless / SSH)"} {
192+ plain := " " + label
193+ if idx == d.cursor {
194+ lines = append(lines, th.PadHighlight(plain, width))
195+ } else {
196+ lines = append(lines, th.FG256(th.Muted, plain))
197+ }
198+ }
199+ lines = append(lines, frameRule(th, width))
186 case loginStepLlamaURL:200 case loginStepLlamaURL:
187 lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width))201 lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width))
188 lines = append(lines, th.FG256(th.Muted, "server URL:"))202 lines = append(lines, th.FG256(th.Muted, "server URL:"))
@@ -216,7 +230,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
216 lines = append(lines, th.FG256(th.Accent, seg))230 lines = append(lines, th.FG256(th.Accent, seg))
217 }231 }
218 lines = append(lines, "")232 lines = append(lines, "")
219- if d.provider == "openai-codex" || d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {233+ if d.method == "oauth" {
220 lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))234 lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))
221 lines = append(lines, frameRule(th, width))235 lines = append(lines, frameRule(th, width))
222 break236 break
@@ -447,6 +461,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
447 return d.handleMethodKey(k)461 return d.handleMethodKey(k)
448 case loginStepProvider:462 case loginStepProvider:
449 return d.handleProviderKey(k)463 return d.handleProviderKey(k)
464+ case loginStepOAuthMethod:
465+ return d.handleOAuthMethodKey(k)
450 case loginStepLlamaURL, loginStepLlamaKey:466 case loginStepLlamaURL, loginStepLlamaKey:
451 return d.handleLlamaKey(k)467 return d.handleLlamaKey(k)
452 case loginStepWaiting:468 case loginStepWaiting:
@@ -543,6 +559,11 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
543 d.message = ""559 d.message = ""
544 return loginDialogAction{}560 return loginDialogAction{}
545 }561 }
562+ if d.method == "oauth" && d.provider == "anthropic" {
563+ d.step = loginStepOAuthMethod
564+ d.cursor = 0
565+ return loginDialogAction{}
566+ }
546 d.step = loginStepWaiting567 d.step = loginStepWaiting
547 if d.method == "apikey" {568 if d.method == "apikey" {
548 return loginDialogAction{StartAPIKey: true, Provider: d.provider}569 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
@@ -552,6 +573,26 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
552 return loginDialogAction{}573 return loginDialogAction{}
553 }574 }
554 575
576+func (d *loginDialog) handleOAuthMethodKey(k tui.Key) loginDialogAction {
577+ switch k.Kind {
578+ case tui.KeyUp:
579+ d.cursor = 0
580+ case tui.KeyDown:
581+ d.cursor = 1
582+ case tui.KeyEsc:
583+ d.step = loginStepProvider
584+ d.cursor = 0
585+ case tui.KeyEnter:
586+ if d.cursor == 1 {
587+ d.step = loginStepPasteCode
588+ return loginDialogAction{StartManual: true, Provider: d.provider}
589+ }
590+ d.step = loginStepWaiting
591+ return loginDialogAction{StartOAuth: true, Provider: d.provider}
592+ }
593+ return loginDialogAction{}
594+}
595+
555 func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction {596 func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction {
556 if k.Kind == tui.KeyEsc {597 if k.Kind == tui.KeyEsc {
557 d.Close()598 d.Close()
@@ -597,6 +638,15 @@ func (d *loginDialog) ShowWaiting(url string) {
597 d.url = url638 d.url = url
598 }639 }
599 640
641+// ShowPasteCode displays the URL for a manual OAuth transaction.
642+func (d *loginDialog) ShowPasteCode(url string) {
643+ if d.step == loginStepClosed {
644+ return
645+ }
646+ d.step = loginStepPasteCode
647+ d.url = url
648+}
649+
600 // ShowInfo transitions to an informational setup dialog.650 // ShowInfo transitions to an informational setup dialog.
601 // No-op if the user has already dismissed the dialog.651 // No-op if the user has already dismissed the dialog.
602 func (d *loginDialog) ShowInfo(title string, lines []string) {652 func (d *loginDialog) ShowInfo(title string, lines []string) {
@@ -624,7 +674,7 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
624 d.Close()674 d.Close()
625 return loginDialogAction{Close: true}675 return loginDialogAction{Close: true}
626 }676 }
627- if d.codeEd == nil {677+ if d.method == "oauth" || d.codeEd == nil {
628 return loginDialogAction{}678 return loginDialogAction{}
629 }679 }
630 if submit := d.codeEd.HandleKey(k); submit {680 if submit := d.codeEd.HandleKey(k); submit {
@@ -667,7 +717,7 @@ func (d *loginDialog) CursorPos(width int) (row, col int) {
667 }717 }
668 return baseOffset + eRow, eCol718 return baseOffset + eRow, eCol
669 }719 }
670- if d.codeEd == nil {720+ if d.codeEd == nil || (d.step == loginStepWaiting && d.method == "oauth") {
671 return -1, -1721 return -1, -1
672 }722 }
673 if d.step != loginStepPasteCode && d.step != loginStepWaiting {723 if d.step != loginStepPasteCode && d.step != loginStepWaiting {
M packages/agent/modes/login_dialog_test.go +78 -1
@@ -1,9 +1,13 @@
11 package modes
22
33 import (
4+ "context"
5+ "net/url"
6+ "path/filepath"
47 "strings"
58 "testing"
69
10+ "github.com/patriceckhart/zot/packages/provider/auth"
711 "github.com/patriceckhart/zot/packages/tui"
812 )
913
@@ -85,12 +89,85 @@ func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) {
8589 }
8690 }
8791
92+func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) {
93+ for _, tc := range []struct {
94+ name string
95+ choice int
96+ manual bool
97+ step loginStep
98+ }{
99+ {name: "browser", choice: 0, step: loginStepWaiting},
100+ {name: "copy code", choice: 1, manual: true, step: loginStepPasteCode},
101+ } {
102+ t.Run(tc.name, func(t *testing.T) {
103+ d := newLoginDialog()
104+ d.Open(t.TempDir())
105+ d.method = "oauth"
106+ d.step = loginStepProvider
107+ for idx, p := range d.providerOptions() {
108+ if p == "anthropic" {
109+ d.cursor = idx
110+ break
111+ }
112+ }
113+ if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.StartOAuth || action.StartManual || d.step != loginStepOAuthMethod {
114+ t.Fatalf("provider selection started OAuth before choosing a flow: action=%+v step=%v", action, d.step)
115+ }
116+ for i := 0; i < tc.choice; i++ {
117+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
118+ }
119+ action := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
120+ if action.StartManual != tc.manual || action.StartOAuth == tc.manual || d.step != tc.step {
121+ t.Fatalf("flow action=%+v step=%v", action, d.step)
122+ }
123+ if tc.manual {
124+ d.ShowPasteCode("https://example.com/oauth/authorize")
125+ } else {
126+ d.ShowWaiting("https://example.com/oauth/authorize")
127+ }
128+ text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
129+ if strings.Contains(text, "paste the authorization code") != tc.manual {
130+ t.Fatalf("wrong input for %s flow: %s", tc.name, text)
131+ }
132+ })
133+ }
134+}
135+
136+func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) {
137+ manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json")))
138+ t.Cleanup(manager.Close)
139+ i := NewInteractive(InteractiveConfig{AuthManager: manager})
140+ i.dialog.Open(t.TempDir())
141+ i.dialog.method = "oauth"
142+ i.dialog.provider = "anthropic"
143+ i.dialog.step = loginStepOAuthMethod
144+ i.dialog.HandleKey(tui.Key{Kind: tui.KeyDown})
145+ act := i.dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
146+ if !act.StartManual || act.Provider != "anthropic" {
147+ t.Fatalf("expected manual Anthropic transaction, got %+v", act)
148+ }
149+ i.startManualOAuthFlow(act.Provider)
150+ if i.dialog.step != loginStepPasteCode {
151+ t.Fatalf("step = %v, want paste code", i.dialog.step)
152+ }
153+ u, err := url.Parse(i.dialog.url)
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ if got := u.Query().Get("redirect_uri"); got != auth.AnthropicManualOAuth.RedirectURI() {
158+ t.Fatalf("redirect URI = %q, want copy-code redirect", got)
159+ }
160+ if err := manager.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
161+ t.Fatalf("manual transaction not ready for code entry: %v", err)
162+ }
163+}
164+
88165 func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {
89166 d := newLoginDialog()
90167 d.Open(t.TempDir())
91168 d.method = "oauth"
92169 d.provider = "anthropic"
93- d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")
170+ d.ShowPasteCode("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")
94171
95172 lines := padDialogFrame(d.Render(tui.Theme{}, 80))
96173 row, _ := d.CursorPos(80)
@@ -1,9 +1,13 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
5+ "net/url"
6+ "path/filepath"
4 "strings"7 "strings"
5 "testing"8 "testing"
6 9
10+ "github.com/patriceckhart/zot/packages/provider/auth"
7 "github.com/patriceckhart/zot/packages/tui"11 "github.com/patriceckhart/zot/packages/tui"
8 )12 )
9 13
@@ -85,12 +89,85 @@ func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) {
85 }89 }
86 }90 }
87 91
92+func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) {
93+ for _, tc := range []struct {
94+ name string
95+ choice int
96+ manual bool
97+ step loginStep
98+ }{
99+ {name: "browser", choice: 0, step: loginStepWaiting},
100+ {name: "copy code", choice: 1, manual: true, step: loginStepPasteCode},
101+ } {
102+ t.Run(tc.name, func(t *testing.T) {
103+ d := newLoginDialog()
104+ d.Open(t.TempDir())
105+ d.method = "oauth"
106+ d.step = loginStepProvider
107+ for idx, p := range d.providerOptions() {
108+ if p == "anthropic" {
109+ d.cursor = idx
110+ break
111+ }
112+ }
113+ if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.StartOAuth || action.StartManual || d.step != loginStepOAuthMethod {
114+ t.Fatalf("provider selection started OAuth before choosing a flow: action=%+v step=%v", action, d.step)
115+ }
116+ for i := 0; i < tc.choice; i++ {
117+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
118+ }
119+ action := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
120+ if action.StartManual != tc.manual || action.StartOAuth == tc.manual || d.step != tc.step {
121+ t.Fatalf("flow action=%+v step=%v", action, d.step)
122+ }
123+ if tc.manual {
124+ d.ShowPasteCode("https://example.com/oauth/authorize")
125+ } else {
126+ d.ShowWaiting("https://example.com/oauth/authorize")
127+ }
128+ text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
129+ if strings.Contains(text, "paste the authorization code") != tc.manual {
130+ t.Fatalf("wrong input for %s flow: %s", tc.name, text)
131+ }
132+ })
133+ }
134+}
135+
136+func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) {
137+ manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json")))
138+ t.Cleanup(manager.Close)
139+ i := NewInteractive(InteractiveConfig{AuthManager: manager})
140+ i.dialog.Open(t.TempDir())
141+ i.dialog.method = "oauth"
142+ i.dialog.provider = "anthropic"
143+ i.dialog.step = loginStepOAuthMethod
144+ i.dialog.HandleKey(tui.Key{Kind: tui.KeyDown})
145+ act := i.dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
146+ if !act.StartManual || act.Provider != "anthropic" {
147+ t.Fatalf("expected manual Anthropic transaction, got %+v", act)
148+ }
149+ i.startManualOAuthFlow(act.Provider)
150+ if i.dialog.step != loginStepPasteCode {
151+ t.Fatalf("step = %v, want paste code", i.dialog.step)
152+ }
153+ u, err := url.Parse(i.dialog.url)
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ if got := u.Query().Get("redirect_uri"); got != auth.AnthropicManualOAuth.RedirectURI() {
158+ t.Fatalf("redirect URI = %q, want copy-code redirect", got)
159+ }
160+ if err := manager.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
161+ t.Fatalf("manual transaction not ready for code entry: %v", err)
162+ }
163+}
164+
88 func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {165 func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {
89 d := newLoginDialog()166 d := newLoginDialog()
90 d.Open(t.TempDir())167 d.Open(t.TempDir())
91 d.method = "oauth"168 d.method = "oauth"
92 d.provider = "anthropic"169 d.provider = "anthropic"
93- d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")170+ d.ShowPasteCode("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")
94 171
95 lines := padDialogFrame(d.Render(tui.Theme{}, 80))172 lines := padDialogFrame(d.Render(tui.Theme{}, 80))
96 row, _ := d.CursorPos(80)173 row, _ := d.CursorPos(80)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close