Skip to content

Commit 9374d06

Browse files
idoubiclaude
andcommitted
feat: interactive /login wizard with multi-provider support
/login without args launches an interactive step-by-step wizard: Step 1: Select provider (1.Anthropic 2.OpenAI 3.OpenRouter 4.Custom) Step 2: Enter API key Step 3: Enter model name Step 4 (Custom): Enter provider name + base URL Features: - Rendered in a bordered box with step-by-step prompts - Esc to cancel at any step - Auto-sets provider field ("anthropic" or "openai") in settings.json - Auto-sets baseURL for known providers - /login <key> still works as quick mode (auto-detects from key prefix) - SaveProviderConfig exported for reuse Supported providers: - Anthropic (Claude): sonnet-4-6, opus-4-6, haiku-4-5 - OpenAI (GPT): gpt-4o, gpt-4o-mini, o3 - OpenRouter: 200+ models (anthropic/claude-sonnet-4-5, etc.) - Custom: any OpenAI-compatible endpoint (DeepSeek, Ollama, Groq, etc.) 6200+ lines Go, 50 commands Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 34cdd80 commit 9374d06

3 files changed

Lines changed: 243 additions & 8 deletions

File tree

internal/slash/commands.go

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -714,18 +714,32 @@ func (h *Handler) planToggle() Result {
714714

715715
func (h *Handler) loginCmd(args []string) Result {
716716
if len(args) == 0 {
717-
return Result{Message: "Usage: /login <api-key>\n\nSet your API key. Examples:\n /login sk-ant-... (Anthropic)\n /login sk-or-v1-... (OpenRouter)\n\nOr set via environment variable:\n export ANTHROPIC_API_KEY=sk-ant-...\n export CODEANY_API_KEY=sk-or-...\n export CODEANY_BASE_URL=https://openrouter.ai/api"}
717+
// Start interactive wizard
718+
return Result{StartLogin: true}
718719
}
719720

721+
// Quick mode: /login <api-key>
720722
apiKey := args[0]
723+
return saveAPIKey(apiKey)
724+
}
721725

722-
// Detect provider from key prefix
726+
// saveAPIKey saves an API key and auto-detects provider
727+
func saveAPIKey(apiKey string) Result {
723728
provider := "anthropic"
729+
baseURL := ""
724730
if strings.HasPrefix(apiKey, "sk-or-") {
725731
provider = "openrouter"
732+
baseURL = "https://openrouter.ai/api"
733+
} else if strings.HasPrefix(apiKey, "sk-") && !strings.HasPrefix(apiKey, "sk-ant-") {
734+
provider = "openai"
735+
baseURL = "https://api.openai.com/v1"
726736
}
727737

728-
// Save to settings.json
738+
return SaveProviderConfig(provider, apiKey, baseURL, "")
739+
}
740+
741+
// SaveProviderConfig writes provider config to settings.json
742+
func SaveProviderConfig(provider, apiKey, baseURL, model string) Result {
729743
settingsPath := config.GlobalConfigPath()
730744
var settings map[string]interface{}
731745

@@ -737,8 +751,16 @@ func (h *Handler) loginCmd(args []string) Result {
737751
}
738752

739753
settings["apiKey"] = apiKey
740-
if provider == "openrouter" {
741-
settings["baseURL"] = "https://openrouter.ai/api"
754+
if provider == "openai" || provider == "openrouter" || provider == "custom" {
755+
settings["provider"] = "openai"
756+
} else {
757+
settings["provider"] = "anthropic"
758+
}
759+
if baseURL != "" {
760+
settings["baseURL"] = baseURL
761+
}
762+
if model != "" {
763+
settings["model"] = model
742764
}
743765

744766
data, _ := json.MarshalIndent(settings, "", " ")
@@ -747,7 +769,7 @@ func (h *Handler) loginCmd(args []string) Result {
747769
return Result{Message: fmt.Sprintf("Failed to save: %v", err)}
748770
}
749771

750-
return Result{Message: fmt.Sprintf("✓ API key saved (%s provider)\n Stored in %s", provider, settingsPath)}
772+
return Result{Message: fmt.Sprintf("✓ Logged in (%s)\n Stored in %s\n Restart codeany to apply changes.", provider, settingsPath)}
751773
}
752774

753775
// ─── /logout ──────────────────────────────────────

internal/slash/slash.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type Result struct {
3030
PlanToggle bool // Toggle plan mode
3131
SessionTitle string // Rename current session
3232
VimToggle bool // Toggle vim mode
33+
StartLogin bool // Start interactive login wizard
3334
}
3435

3536
// CommandDef defines a slash command with metadata

internal/tui/model.go

Lines changed: 214 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const (
3333
stateInput
3434
stateQuerying
3535
statePermission
36+
stateLogin // interactive login wizard
3637
)
3738

3839
// Tea messages
@@ -130,6 +131,19 @@ type Model struct {
130131

131132
// Queued messages (typed during query execution)
132133
queuedMessages []string
134+
135+
// Login wizard state
136+
loginWizard *LoginWizard
137+
}
138+
139+
// LoginWizard tracks the multi-step login flow
140+
type LoginWizard struct {
141+
Step int // 0=provider, 1=apikey, 2=baseurl, 3=model, 4=name
142+
Provider string // "anthropic", "openai", "openrouter", "custom"
143+
ProviderName string // display name (for custom)
144+
APIKey string
145+
BaseURL string
146+
Model string
133147
}
134148

135149
// DisplayBlock represents one visual block in the conversation
@@ -485,8 +499,9 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
485499
case statePermission:
486500
return m.handlePermissionKey(msg)
487501
case stateQuerying:
488-
// Allow scrolling and typing during query
489502
return m.handleQueryingKey(msg)
503+
case stateLogin:
504+
return m.handleLoginKey(msg)
490505
}
491506

492507
return m, nil
@@ -664,8 +679,15 @@ func (m *Model) handleInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
664679
Type: "system", Content: "Vim mode toggled (visual indicator only in this version)", Timestamp: time.Now(),
665680
})
666681
}
682+
// Start login wizard
683+
if result.StartLogin {
684+
m.loginWizard = &LoginWizard{Step: 0}
685+
m.state = stateLogin
686+
m.input.Focus()
687+
m.refreshViewport()
688+
return m, nil
689+
}
667690
m.refreshViewport()
668-
// If the command produced a prompt for the agent, send it
669691
if result.SkillPrompt != "" {
670692
return m, m.sendQuery(result.SkillPrompt)
671693
}
@@ -902,6 +924,8 @@ func (m *Model) View() string {
902924
if len(m.queuedMessages) > 0 {
903925
b.WriteString(theme.DimText.Render(fmt.Sprintf(" (%d queued)", len(m.queuedMessages))))
904926
}
927+
case stateLogin:
928+
b.WriteString(m.renderLoginWizard())
905929
case stateInit:
906930
b.WriteString(fmt.Sprintf(" %s Initializing...", m.spinner.View()))
907931
default:
@@ -1463,6 +1487,194 @@ func (m *Model) SendPrompt(prompt string) {
14631487
// Handled via SkillPrompt in handleInputKey
14641488
}
14651489

1490+
// ─── Login Wizard ───────────────────────────────
1491+
1492+
func (m *Model) handleLoginKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
1493+
if m.loginWizard == nil {
1494+
m.state = stateInput
1495+
return m, nil
1496+
}
1497+
1498+
// Esc to cancel
1499+
if msg.String() == "esc" {
1500+
m.loginWizard = nil
1501+
m.state = stateInput
1502+
m.input.Reset()
1503+
m.blocks = append(m.blocks, DisplayBlock{
1504+
Type: "system", Content: "Login cancelled.", Timestamp: time.Now(),
1505+
})
1506+
m.refreshViewport()
1507+
return m, nil
1508+
}
1509+
1510+
w := m.loginWizard
1511+
1512+
switch w.Step {
1513+
case 0: // Provider selection (1-4)
1514+
switch msg.String() {
1515+
case "1":
1516+
w.Provider = "anthropic"
1517+
w.BaseURL = "https://api.anthropic.com"
1518+
w.Step = 1
1519+
m.input.Reset()
1520+
case "2":
1521+
w.Provider = "openai"
1522+
w.BaseURL = "https://api.openai.com/v1"
1523+
w.Step = 1
1524+
m.input.Reset()
1525+
case "3":
1526+
w.Provider = "openrouter"
1527+
w.BaseURL = "https://openrouter.ai/api"
1528+
w.Step = 1
1529+
m.input.Reset()
1530+
case "4":
1531+
w.Provider = "custom"
1532+
w.Step = 4 // name first
1533+
m.input.Reset()
1534+
}
1535+
m.refreshViewport()
1536+
return m, nil
1537+
1538+
case 1: // API Key
1539+
submitted, cmd := m.input.Update(msg)
1540+
if submitted {
1541+
w.APIKey = m.input.Value()
1542+
m.input.Reset()
1543+
if w.Provider == "custom" {
1544+
w.Step = 2 // base URL
1545+
} else {
1546+
w.Step = 3 // model
1547+
}
1548+
m.refreshViewport()
1549+
return m, nil
1550+
}
1551+
return m, cmd
1552+
1553+
case 2: // Base URL (custom only)
1554+
submitted, cmd := m.input.Update(msg)
1555+
if submitted {
1556+
w.BaseURL = m.input.Value()
1557+
m.input.Reset()
1558+
w.Step = 3 // model
1559+
m.refreshViewport()
1560+
return m, nil
1561+
}
1562+
return m, cmd
1563+
1564+
case 3: // Model name
1565+
submitted, cmd := m.input.Update(msg)
1566+
if submitted {
1567+
w.Model = m.input.Value()
1568+
m.input.Reset()
1569+
// Done! Save config
1570+
m.finishLogin()
1571+
return m, nil
1572+
}
1573+
return m, cmd
1574+
1575+
case 4: // Provider name (custom)
1576+
submitted, cmd := m.input.Update(msg)
1577+
if submitted {
1578+
w.ProviderName = m.input.Value()
1579+
m.input.Reset()
1580+
w.Step = 1 // API key
1581+
m.refreshViewport()
1582+
return m, nil
1583+
}
1584+
return m, cmd
1585+
}
1586+
1587+
return m, nil
1588+
}
1589+
1590+
func (m *Model) finishLogin() {
1591+
w := m.loginWizard
1592+
1593+
result := slash.SaveProviderConfig(w.Provider, w.APIKey, w.BaseURL, w.Model)
1594+
1595+
m.blocks = append(m.blocks, DisplayBlock{
1596+
Type: "system", Content: result.Message, Timestamp: time.Now(),
1597+
})
1598+
1599+
m.loginWizard = nil
1600+
m.state = stateInput
1601+
m.input.Focus()
1602+
m.refreshViewport()
1603+
}
1604+
1605+
func (m *Model) renderLoginWizard() string {
1606+
if m.loginWizard == nil {
1607+
return ""
1608+
}
1609+
1610+
w := m.loginWizard
1611+
boxStyle := lipgloss.NewStyle().
1612+
Border(lipgloss.RoundedBorder()).
1613+
BorderForeground(theme.Secondary).
1614+
Padding(0, 1).
1615+
MarginLeft(2).
1616+
Width(60)
1617+
1618+
title := theme.SecondaryText.Bold(true).Render("Login Setup")
1619+
var content string
1620+
1621+
switch w.Step {
1622+
case 0:
1623+
content = title + "\n\n" +
1624+
" Select a provider:\n\n" +
1625+
theme.PrimaryText.Render(" [1]") + " Anthropic (Claude)\n" +
1626+
theme.PrimaryText.Render(" [2]") + " OpenAI (GPT)\n" +
1627+
theme.PrimaryText.Render(" [3]") + " OpenRouter (200+ models)\n" +
1628+
theme.PrimaryText.Render(" [4]") + " Custom provider\n\n" +
1629+
theme.DimText.Render(" Press 1-4 to select, Esc to cancel")
1630+
1631+
case 1:
1632+
provLabel := w.Provider
1633+
if w.ProviderName != "" {
1634+
provLabel = w.ProviderName
1635+
}
1636+
content = title + fmt.Sprintf(" — %s", provLabel) + "\n\n" +
1637+
" Enter your API key:\n\n" +
1638+
m.input.View() + "\n\n" +
1639+
theme.DimText.Render(" Press Enter to continue, Esc to cancel")
1640+
1641+
case 2:
1642+
content = title + " — Base URL\n\n" +
1643+
" Enter the API base URL:\n" +
1644+
theme.DimText.Render(" (e.g., https://api.example.com/v1)") + "\n\n" +
1645+
m.input.View() + "\n\n" +
1646+
theme.DimText.Render(" Press Enter to continue, Esc to cancel")
1647+
1648+
case 3:
1649+
defaultModel := ""
1650+
switch w.Provider {
1651+
case "anthropic":
1652+
defaultModel = "sonnet-4-6"
1653+
case "openai":
1654+
defaultModel = "gpt-4o"
1655+
case "openrouter":
1656+
defaultModel = "anthropic/claude-sonnet-4-5"
1657+
}
1658+
hint := ""
1659+
if defaultModel != "" {
1660+
hint = fmt.Sprintf("\n %s", theme.DimText.Render("(e.g., "+defaultModel+")"))
1661+
}
1662+
content = title + " — Model\n\n" +
1663+
" Enter the default model name:" + hint + "\n\n" +
1664+
m.input.View() + "\n\n" +
1665+
theme.DimText.Render(" Press Enter to finish, Esc to cancel")
1666+
1667+
case 4:
1668+
content = title + " — Custom Provider\n\n" +
1669+
" Enter provider name:\n" +
1670+
theme.DimText.Render(" (e.g., deepseek, together, groq)") + "\n\n" +
1671+
m.input.View() + "\n\n" +
1672+
theme.DimText.Render(" Press Enter to continue, Esc to cancel")
1673+
}
1674+
1675+
return boxStyle.Render(content)
1676+
}
1677+
14661678
// ─── Conversation Persistence ───────────────────
14671679

14681680
func (m *Model) saveConversation() {

0 commit comments

Comments
 (0)