Vectrum
Sign In

tui: ask about the Claude Code login, and a row for it in /settings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 8d9f14ccommit48191e7

Showing 6 changed files with 295 additions and 5 deletions.

+21−0internal/tui/model.goView File
@@ -134,6 +134,20 @@ type Options struct {
134134 // config reads.
135135 Diagnostics string
136136
137+ // OfferClaudeCodeAuth says the question of borrowing Claude Code's login
138+ // arises on this run: no key or token was supplied, the endpoint is
139+ // Anthropic's, and Claude Code is logged in here. It is the only thing
140+ // that puts the question in the wizard — someone with an API key has
141+ // nothing to decide. Decided by the caller, because it is about the flags,
142+ // the environment and the disk, none of which are the screen's.
143+ OfferClaudeCodeAuth bool
144+ // ClaudeCodeAuth is whether this session is running on that login: on
145+ // when it is, and empty when the caller has nothing to say — the screens
146+ // then read the config. Like Diagnostics it is what was delivered, so the
147+ // screens can say when an answer waits for the next start: the client was
148+ // built, credential and all, before the first frame.
149+ ClaudeCodeAuth string
150+
137151 // NewSession builds a second conversation for a new tab, rooted at the
138152 // directory it is given. The TUI cannot build one itself: an agent is a
139153 // client, a tool set, a state and a policy, and which of those a run is
@@ -306,6 +320,7 @@ type model struct {
306320 setWeb string
307321 setClaude string
308322 setDiag string
323+ setCCAuth string
309324
310325 // The model picker: what the endpoint offered, where the cursor is, and
311326 // whether we are still waiting to be told.
@@ -568,6 +583,7 @@ func (m *model) openWizard() tea.Cmd {
568583 // palette; nothing selected underneath it survives that.
569584 m.clearSelection()
570585 m.wiz = newWizard(m.opts.Client, m.opts.Config, m.opts.ClaudeSkills, m.opts.HerdrPresent && m.opts.InstallHerdrSkill != nil)
586+ m.wiz.offerClaudeCodeAuth = m.opts.OfferClaudeCodeAuth
571587 if m.ready {
572588 // The wizard has never seen a resize, so it is told the size the model
573589 // already knows rather than rendering into a zero-sized screen until
@@ -628,6 +644,11 @@ func (m *model) closeWizard() tea.Cmd {
628644 if m.opts.Policy != nil && m.opts.Policy.YOLO {
629645 m.tr.addNotice("accept-all is on — tool calls run without asking.")
630646 }
647+ // Same again for the Claude Code login: the client this session talks
648+ // through was built, credential and all, before the wizard opened.
649+ if want := claudeCodeAuthMode(cfg); w.offerClaudeCodeAuth && m.opts.ClaudeCodeAuth != "" && want != m.opts.ClaudeCodeAuth {
650+ m.tr.addNotice(claudeCodeAuthRestartNote(want))
651+ }
631652 if cfg.ClaudeSkillsMode() == config.ClaudeSkillsCopy && m.opts.CopyClaudeSkills != nil {
632653 // Done here rather than as a command: it is a copy between two
633654 // directories on the same disk, over in the time a frame takes, and a
+39−1internal/tui/settings.goView File
@@ -28,13 +28,14 @@ const (
2828 setApprovalsRow
2929 setWebSearchRow
3030 setDiagnosticsRow
31+ setClaudeCodeAuthRow
3132 setClaudeSkillsRow
3233 setThemeRow
3334 numSettingRows
3435 )
3536
3637 var settingLabels = [numSettingRows]string{
37- "Model", "Effort", "Approvals", "Web search", "Diagnostics", "Claude skills", "Theme",
38+ "Model", "Effort", "Approvals", "Web search", "Diagnostics", "Claude Code login", "Claude skills", "Theme",
3839 }
3940
4041 // settingsHeadRows is how many rows sit above the list: the heading, which
@@ -49,6 +50,7 @@ func (m *model) openSettings() tea.Cmd {
4950 m.setModelWas = m.modelInForce()
5051 m.setWeb = m.opts.Config.WebSearchMode()
5152 m.setClaude = m.opts.Config.ClaudeSkillsMode()
53+ m.setCCAuth = claudeCodeAuthMode(m.opts.Config)
5254 m.setDiag = diagnosticsInForce(m.opts.Config)
5355 // A theme change repaints everything, including whatever was marked.
5456 m.clearSelection()
@@ -170,6 +172,8 @@ func (m *model) settingsConfig() config.Config {
170172 cfg.WebSearch = v
171173 case setDiagnosticsRow:
172174 cfg.Diagnostics = v
175+ case setClaudeCodeAuthRow:
176+ cfg.ClaudeCodeAuth = v
173177 case setClaudeSkillsRow:
174178 cfg.ClaudeSkills = v
175179 case setThemeRow:
@@ -197,6 +201,11 @@ func settingsSummary(cfg config.Config) string {
197201 if mode := cfg.ClaudeSkillsMode(); mode != config.ClaudeSkillsOff {
198202 s += " · claude skills " + mode
199203 }
204+ // Named only when on, like web search: it is the exception, and the one
205+ // worth being reminded of.
206+ if cfg.ClaudeCodeAuthEnabled() {
207+ s += " · claude code login"
208+ }
200209 return s
201210 }
202211
@@ -236,6 +245,8 @@ func (m *model) settingValue(r settingRow) string {
236245 return config.Or(m.setWeb, m.webSearchInForce())
237246 case setDiagnosticsRow:
238247 return config.Or(m.setDiag, diagnosticsInForce(m.opts.Config))
248+ case setClaudeCodeAuthRow:
249+ return config.Or(m.setCCAuth, claudeCodeAuthMode(m.opts.Config))
239250 case setClaudeSkillsRow:
240251 return config.Or(m.setClaude, m.opts.Config.ClaudeSkillsMode())
241252 case setThemeRow:
@@ -263,6 +274,8 @@ func settingOptions(r settingRow) []wizOption {
263274 return webSearchOptions()
264275 case setDiagnosticsRow:
265276 return diagnosticsOptions()
277+ case setClaudeCodeAuthRow:
278+ return claudeCodeAuthOptions()
266279 case setClaudeSkillsRow:
267280 return claudeSkillsOptions()
268281 case setThemeRow:
@@ -307,6 +320,10 @@ func (m *model) useSetting(r settingRow, v string) {
307320 // Same again: whether an edit is checked was decided when the edit
308321 // tool was built, so this row edits the file and says so.
309322 m.setDiag = v
323+ case setClaudeCodeAuthRow:
324+ // And again: the client, credential and all, was built before the
325+ // first frame was drawn.
326+ m.setCCAuth = v
310327 case setClaudeSkillsRow:
311328 // And again: which skills the model was told about went into the
312329 // first request of the session, which has already gone out.
@@ -450,6 +467,9 @@ func (m *model) settingDetail(r settingRow) string {
450467 if r == setClaudeSkillsRow && v != m.claudeSkillsInForce() {
451468 desc += " · takes effect next start"
452469 }
470+ if r == setClaudeCodeAuthRow && v != m.claudeCodeAuthInForce() {
471+ desc += " · takes effect next start"
472+ }
453473 return desc
454474 }
455475 }
@@ -509,6 +529,24 @@ func (m *model) claudeSkillsInForce() string {
509529 return config.Or(m.opts.ClaudeSkillsMode, m.opts.Config.ClaudeSkillsMode())
510530 }
511531
532+ // claudeCodeAuthInForce is what this session is running on, falling back to
533+ // the config where main did not say — which it does not when the question
534+ // never arose, because a key was given or the endpoint is not Anthropic's.
535+ // There the setting changes nothing on the next start either, and a panel
536+ // promising otherwise would be wrong.
537+ func (m *model) claudeCodeAuthInForce() string {
538+ return config.Or(m.opts.ClaudeCodeAuth, claudeCodeAuthMode(m.opts.Config))
539+ }
540+
541+ // claudeCodeAuthRestartNote is what the wizard says when its answer about the
542+ // Claude Code login is not the one this session started under.
543+ func claudeCodeAuthRestartNote(mode string) string {
544+ if mode == config.ClaudeCodeAuthOn {
545+ return "Claude Code login: on — saved; it is borrowed from the next start of codey, when no API key is set"
546+ }
547+ return "Claude Code login: off — saved; from the next start codey needs ANTHROPIC_API_KEY or a gateway token"
548+ }
549+
512550 // webSearchRestartNote is what both screens say when the web-search answer is
513551 // one this process cannot take on. It names the restart because that is the
514552 // whole of the fix, and there is no way to do it from inside the session: the
+49−0internal/tui/settings_test.goView File
@@ -470,6 +470,55 @@ func TestSettingsClaudeSkillsRow(t *testing.T) {
470470 }
471471 }
472472
473+ // The Claude Code login row: off unless the file says on, a change waits for
474+ // the next start, and only a touched row is written.
475+ func TestSettingsClaudeCodeLoginRow(t *testing.T) {
476+ m := newTestModel(t)
477+ m.openSettings()
478+ m.setIdx = setClaudeCodeAuthRow
479+
480+ if got := m.settingValue(setClaudeCodeAuthRow); got != config.ClaudeCodeAuthOff {
481+ t.Fatalf("an empty config opened the row on %q, want %q", got, config.ClaudeCodeAuthOff)
482+ }
483+ if strings.Contains(m.settingDetail(setClaudeCodeAuthRow), "next start") {
484+ t.Error("an unchanged row warns about a restart it does not need")
485+ }
486+ if cfg := m.settingsConfig(); cfg.ClaudeCodeAuth != "" {
487+ t.Errorf("an untouched row wrote %q", cfg.ClaudeCodeAuth)
488+ }
489+
490+ m.cycleSetting(setClaudeCodeAuthRow, 1)
491+ if got := m.settingValue(setClaudeCodeAuthRow); got != config.ClaudeCodeAuthOn {
492+ t.Fatalf("cycling landed on %q, want %q", got, config.ClaudeCodeAuthOn)
493+ }
494+ detail := m.settingDetail(setClaudeCodeAuthRow)
495+ if !strings.Contains(detail, "next start") || !strings.Contains(detail, "terms") {
496+ t.Errorf("the detail does not say when it takes effect, or what the terms are: %q", detail)
497+ }
498+ cfg := m.settingsConfig()
499+ if cfg.ClaudeCodeAuth != config.ClaudeCodeAuthOn {
500+ t.Errorf("saved %q, want %q", cfg.ClaudeCodeAuth, config.ClaudeCodeAuthOn)
501+ }
502+ if !strings.Contains(settingsSummary(cfg), "claude code login") {
503+ t.Errorf("the summary does not mention the login: %q", settingsSummary(cfg))
504+ }
505+ }
506+
507+ // Where the question never arose — a key was given — main says nothing about
508+ // the session, and the row reads the file without promising a restart would
509+ // change anything.
510+ func TestSettingsClaudeCodeLoginRowWithAKey(t *testing.T) {
511+ m := newTestModel(t)
512+ m.opts.Config = config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn}
513+ m.openSettings()
514+ if got := m.settingValue(setClaudeCodeAuthRow); got != config.ClaudeCodeAuthOn {
515+ t.Fatalf("the row opened on %q, want the configured %q", got, config.ClaudeCodeAuthOn)
516+ }
517+ if strings.Contains(m.settingDetail(setClaudeCodeAuthRow), "next start") {
518+ t.Error("a row the session is not affected by warns about a restart")
519+ }
520+ }
521+
473522 // Every row has a label wide enough not to collide with its value. The panel
474523 // measures the column, and this is what says it still does.
475524 func TestSettingsLabelsDoNotRunIntoTheirValues(t *testing.T) {
+55−4internal/tui/wizard.goView File
@@ -9,6 +9,7 @@ import (
99 "github.com/charmbracelet/bubbles/textinput"
1010 tea "github.com/charmbracelet/bubbletea"
1111
12+ "github.com/johannSo/codey/internal/claudecode"
1213 "github.com/johannSo/codey/internal/config"
1314 "github.com/johannSo/codey/internal/llm"
1415 "github.com/johannSo/codey/internal/skills"
@@ -33,6 +34,7 @@ const (
3334 stepApprovals
3435 stepWebSearch
3536 stepTheme
37+ stepClaudeCodeAuth
3638 stepClaudeSkills
3739 stepHerdr
3840 stepDone
@@ -43,15 +45,19 @@ const (
4345 // positions on it.
4446 const baseRailSteps = 5
4547
46- // questions is the steps this run will ask, in order. The two at the end are
47- // conditional — Claude's skills are only offered on a machine that has some,
48- // herdr's only where herdr is — so the rail, the "step n of m" counter and the
48+ // questions is the steps this run will ask, in order. The three at the end are
49+ // conditional — the Claude Code login is only asked about where it could be
50+ // borrowed, Claude's skills only on a machine that has some, herdr's only
51+ // where herdr is — so the rail, the "step n of m" counter and the
4952 // next/previous arithmetic all read this list rather than the step's own
5053 // ordinal. With one conditional step it was enough to keep it last; with two,
5154 // the second is in the middle whenever the first is skipped, and a node the
5255 // marker jumps over is worse than no node.
5356 func (w *wizard) questions() []wizStep {
5457 q := []wizStep{stepModel, stepEffort, stepApprovals, stepWebSearch, stepTheme}
58+ if w.offerClaudeCodeAuth {
59+ q = append(q, stepClaudeCodeAuth)
60+ }
5561 if w.claudeSkills > 0 {
5662 q = append(q, stepClaudeSkills)
5763 }
@@ -172,6 +178,12 @@ type wizard struct {
172178 // where "some skills" is a mystery.
173179 claudeSkills int
174180
181+ // offerClaudeCodeAuth is whether to ask about borrowing Claude Code's
182+ // login: true only where no key or token was supplied, the endpoint is
183+ // Anthropic's, and Claude Code is logged in here. Someone with an API key
184+ // has nothing to decide, and is not asked.
185+ offerClaudeCodeAuth bool
186+
175187 // offerHerdr is whether this machine has herdr, and so whether the skill
176188 // question is asked at all.
177189 offerHerdr bool
@@ -476,6 +488,10 @@ func (w *wizard) advance() tea.Cmd {
476488 w.cfg.Theme = w.options()[w.cursor].value
477489 return w.goTo(w.next(stepTheme))
478490
491+ case stepClaudeCodeAuth:
492+ w.cfg.ClaudeCodeAuth = w.options()[w.cursor].value
493+ return w.goTo(w.next(stepClaudeCodeAuth))
494+
479495 case stepClaudeSkills:
480496 w.cfg.ClaudeSkills = w.options()[w.cursor].value
481497 return w.goTo(w.next(stepClaudeSkills))
@@ -503,7 +519,7 @@ func (w *wizard) back() tea.Cmd {
503519 return w.goTo(stepApprovals)
504520 case stepTheme:
505521 return w.goTo(stepWebSearch)
506- case stepClaudeSkills, stepHerdr:
522+ case stepClaudeCodeAuth, stepClaudeSkills, stepHerdr:
507523 return w.goTo(w.prev(w.step))
508524 }
509525 return nil
@@ -563,6 +579,8 @@ func (w *wizard) current() string {
563579 return w.cfg.WebSearchMode()
564580 case stepTheme:
565581 return w.cfg.Theme
582+ case stepClaudeCodeAuth:
583+ return claudeCodeAuthMode(w.cfg)
566584 case stepClaudeSkills:
567585 return w.cfg.ClaudeSkillsMode()
568586 }
@@ -619,6 +637,8 @@ func (w *wizard) options() []wizOption {
619637 return webSearchOptions()
620638 case stepTheme:
621639 return themeOptions()
640+ case stepClaudeCodeAuth:
641+ return claudeCodeAuthOptions()
622642 case stepClaudeSkills:
623643 return claudeSkillsOptions()
624644 case stepHerdr:
@@ -627,6 +647,37 @@ func (w *wizard) options() []wizOption {
627647 return nil
628648 }
629649
650+ // claudeCodeAuthOptions are the two answers to "borrow Claude Code's login when
651+ // there is no API key".
652+ //
653+ // No is first and the default, and it is the one place where the default is
654+ // not merely the cautious reading but the required one: the login is issued
655+ // to Claude Code, and using it from another client is outside Anthropic's
656+ // terms for it. Both rows say so, because the cursor opens on no and the
657+ // sentence about terms has to be on screen before anyone moves it to yes.
658+ func claudeCodeAuthOptions() []wizOption {
659+ return []wizOption{{
660+ value: config.ClaudeCodeAuthOff,
661+ label: "No, I'll use an API key",
662+ note: "default",
663+ desc: "Codey uses ANTHROPIC_API_KEY, or a gateway's token. Claude Code's login is issued to Claude Code; using it from another client is outside Anthropic's terms for it.",
664+ }, {
665+ value: config.ClaudeCodeAuthOn,
666+ label: "Yes, use my Claude Code login",
667+ note: "this machine only",
668+ desc: "borrows ~/" + claudecode.Filename + " when no key is set, against Anthropic's own endpoint only. It is issued to Claude Code, and using it from another client is outside Anthropic's terms for it.",
669+ }}
670+ }
671+
672+ // claudeCodeAuthMode reads a config's answer as one of the two words, so no
673+ // caller has to spell the empty-means-off rule out again.
674+ func claudeCodeAuthMode(cfg config.Config) string {
675+ if cfg.ClaudeCodeAuthEnabled() {
676+ return config.ClaudeCodeAuthOn
677+ }
678+ return config.ClaudeCodeAuthOff
679+ }
680+
630681 // claudeSkillsOptions are the three things to do with the skills Claude Code
631682 // has on this machine, in the config's order: nothing, read them in place,
632683 // take a copy.
+129−0internal/tui/wizard_test.goView File
@@ -1193,3 +1193,132 @@ func TestCancellingTheWizardCopiesNothing(t *testing.T) {
11931193 t.Errorf("the copy ran %d times for a wizard that was escaped out of", called)
11941194 }
11951195 }
1196+
1197+ // newLoginWizard is newClaudeWizard on a machine where the Claude Code login
1198+ // question does or does not arise, with n Claude skills.
1199+ func newLoginWizard(t *testing.T, offer bool, n int) *wizard {
1200+ t.Helper()
1201+ w := newClaudeWizard(t, n, false)
1202+ w.offerClaudeCodeAuth = offer
1203+ return w
1204+ }
1205+
1206+ // Someone with an API key has nothing to decide, and a machine without
1207+ // Claude Code has nothing to borrow: the question is asked only where main
1208+ // says it arises, and it comes straight after the theme, ahead of the other
1209+ // question about Claude Code.
1210+ func TestTheClaudeCodeLoginStepIsOnlyAskedWhenItArises(t *testing.T) {
1211+ tests := []struct {
1212+ name string
1213+ offer bool
1214+ n int
1215+ want wizStep
1216+ }{
1217+ {name: "not offered", offer: false, n: 0, want: stepDone},
1218+ {name: "not offered, skills", offer: false, n: 3, want: stepClaudeSkills},
1219+ {name: "offered", offer: true, n: 0, want: stepClaudeCodeAuth},
1220+ {name: "offered, skills", offer: true, n: 3, want: stepClaudeCodeAuth},
1221+ }
1222+ for _, tc := range tests {
1223+ t.Run(tc.name, func(t *testing.T) {
1224+ w := newLoginWizard(t, tc.offer, tc.n)
1225+ // Intro, model, effort, approvals, web search, theme.
1226+ enterThrough(w, 6)
1227+ if w.step != tc.want {
1228+ t.Errorf("after the theme step the wizard is on %v, want %v", w.step, tc.want)
1229+ }
1230+ if tc.offer && w.rails() != baseRailSteps+1+boolInt(tc.n > 0) {
1231+ t.Errorf("rails = %d: the login question is not counted", w.rails())
1232+ }
1233+ })
1234+ }
1235+ }
1236+
1237+ func boolInt(b bool) int {
1238+ if b {
1239+ return 1
1240+ }
1241+ return 0
1242+ }
1243+
1244+ // Enter through the step is no: the cursor opens on it, the terms are on
1245+ // screen before anyone moves it, and only moving to yes and pressing enter
1246+ // writes "on".
1247+ func TestTheClaudeCodeLoginStepRecordsTheAnswer(t *testing.T) {
1248+ tests := []struct {
1249+ name string
1250+ down int
1251+ want string
1252+ }{
1253+ {name: "no is the row the cursor opens on", down: 0, want: config.ClaudeCodeAuthOff},
1254+ {name: "yes", down: 1, want: config.ClaudeCodeAuthOn},
1255+ }
1256+ for _, tc := range tests {
1257+ t.Run(tc.name, func(t *testing.T) {
1258+ w := newLoginWizard(t, true, 0)
1259+ enterThrough(w, 6)
1260+ if w.step != stepClaudeCodeAuth {
1261+ t.Fatalf("the walk did not reach the login step, stopped at %v", w.step)
1262+ }
1263+ if view := w.View(); !strings.Contains(view, "Claude Code login") || !strings.Contains(view, "terms") {
1264+ t.Errorf("the step does not ask about the login with the terms on screen:\n%s", view)
1265+ }
1266+
1267+ down(w, tc.down)
1268+ w.Update(key("enter"))
1269+ settle(w)
1270+
1271+ if w.result == nil {
1272+ t.Fatal("answering the last question did not finish the wizard")
1273+ }
1274+ if w.result.ClaudeCodeAuth != tc.want {
1275+ t.Errorf("claude_code_auth = %q, want %q", w.result.ClaudeCodeAuth, tc.want)
1276+ }
1277+ })
1278+ }
1279+ }
1280+
1281+ // Re-running setup opens the step on what is in force, so enter through it
1282+ // changes nothing — and esc goes back to the theme.
1283+ func TestTheClaudeCodeLoginStepPreselectsAndGoesBack(t *testing.T) {
1284+ w := newLoginWizard(t, true, 0)
1285+ w.cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOn
1286+ enterThrough(w, 6)
1287+ if got := w.options()[w.cursor].value; got != config.ClaudeCodeAuthOn {
1288+ t.Errorf("the step opened on %q, want the configured %q", got, config.ClaudeCodeAuthOn)
1289+ }
1290+ w.Update(key("esc"))
1291+ settle(w)
1292+ if w.step != stepTheme {
1293+ t.Errorf("esc from the login step landed on %v, want the theme", w.step)
1294+ }
1295+ }
1296+
1297+ // The wizard is told by main whether to ask, through the options.
1298+ func TestOpenWizardPassesOnTheLoginQuestion(t *testing.T) {
1299+ m := newTestModel(t)
1300+ m.opts.OfferClaudeCodeAuth = true
1301+ m.openWizard()
1302+ if !m.wiz.offerClaudeCodeAuth {
1303+ t.Error("the wizard was not told the login question arises")
1304+ }
1305+ }
1306+
1307+ // The session's client was built before the wizard opened, so an answer that
1308+ // differs from what it is running on says when it will take effect.
1309+ func TestALoginAnswerTheSessionCannotTakeSaysSo(t *testing.T) {
1310+ m := newTestModel(t)
1311+ m.opts.SaveConfig = func(config.Config) error { return nil }
1312+ m.opts.OfferClaudeCodeAuth = true
1313+ m.opts.ClaudeCodeAuth = config.ClaudeCodeAuthOn
1314+
1315+ m.wiz = newWizard(nil, m.opts.Config, 0, false)
1316+ m.wiz.offerClaudeCodeAuth = true
1317+ m.wiz.cfg.ClaudeCodeAuth = config.ClaudeCodeAuthOff
1318+ m.wiz.finish()
1319+ runCmd(t, m.closeWizard())
1320+
1321+ if view := m.tr.render(); !strings.Contains(view, "Claude Code login: off") || !strings.Contains(view, "next start") {
1322+ t.Errorf("turning the login off mid-session does not say when it takes effect:\n%s", view)
1323+ }
1324+ }
+2−0internal/tui/wizard_view.goView File
@@ -164,6 +164,8 @@ func (w *wizard) question() string {
164164 return "Should it be able to search the web?"
165165 case stepTheme:
166166 return "How should it look?"
167+ case stepClaudeCodeAuth:
168+ return "No API key is set. Use your Claude Code login instead?"
167169 case stepClaudeSkills:
168170 // The number is the offer: it says these are real, already on the
169171 // machine, and how much is being agreed to.