From a8de2112f0e63d9e92c79424219f5ccec500a67d Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Wed, 29 Apr 2026 23:15:56 +0000 Subject: [PATCH 01/15] =?UTF-8?q?profiles,tui:=20refactor=20main=20flow=20?= =?UTF-8?q?to=20profile=20=E2=86=92=20provider=20=E2=86=92=20model=20?= =?UTF-8?q?=E2=86=92=20launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the previous flow (profile → backend → launch) with a profile-centric workflow that surfaces provider choice and model selection instead of internal backend types. - Add CompatibleProviders and BackendsForProvider to Manager for provider-centric filtering - Add DedupBackends to collapse backends sharing compat key signatures (e.g. Anthropic and ZAI both use anthropic_messages) - Add ModelSelector interface and ClaudeCodeProfile.ApplyModel for applying user-chosen default models via ANTHROPIC_MODEL - Add ProviderInfo.DisplayName falling back to ID when Name is empty - Add stepSelectProvider and stepSelectModel to the TUI state machine - Show model picker (with FQN provider_id/model_id) when provider has multiple models and profile supports ModelSelector - Persist LastProviderID and LastModel in launcher state --- internal/profiles/claude_code.go | 4 + internal/profiles/profiles.go | 91 ++++++ internal/profiles/profiles_test.go | 238 +++++++++++++++ internal/tui/tui.go | 464 ++++++++++++++++++++--------- 4 files changed, 649 insertions(+), 148 deletions(-) diff --git a/internal/profiles/claude_code.go b/internal/profiles/claude_code.go index 485d0f8..1e21c57 100644 --- a/internal/profiles/claude_code.go +++ b/internal/profiles/claude_code.go @@ -116,6 +116,10 @@ func (c *ClaudeCodeProfile) ProviderEnv(b Backend, providers []ProviderInfo) map return env } +func (c *ClaudeCodeProfile) ApplyModel(model string, env map[string]string) { + env["ANTHROPIC_MODEL"] = model +} + // managedEnvVars returns every environment variable name that the launcher // may set when launching Claude Code, across all backends. var managedEnvVars = []string{ diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index 18b18f9..a3d089a 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -57,6 +57,14 @@ type ProviderInfo struct { Compatibility map[string]bool `json:"compatibility"` } +// DisplayName returns the provider's Name, falling back to ID if Name is empty. +func (p ProviderInfo) DisplayName() string { + if p.Name != "" { + return p.Name + } + return p.ID +} + // CompatChecker is implemented by profiles that declare which API // compatibility keys they require for each backend. The TUI uses this // to hide backends that no provider supports. @@ -70,6 +78,12 @@ type ProviderEnvSetter interface { ProviderEnv(b Backend, providers []ProviderInfo) map[string]string } +// ModelSelector is implemented by profiles that can apply a user-chosen +// default model to their environment variables. +type ModelSelector interface { + ApplyModel(model string, env map[string]string) +} + // Combo is a resolved (profile, backend) pair. type Combo struct { Profile Profile @@ -259,6 +273,81 @@ func anyProviderSupports(providers []ProviderInfo, keys []string) bool { return false } +// CompatibleProviders returns providers whose Compatibility map has at least one +// key matching any of the profile's backends' RequiredCompat keys. If the +// profile does not implement CompatChecker, all providers are returned. +func (m *Manager) CompatibleProviders(p Profile, providers []ProviderInfo) []ProviderInfo { + checker, ok := p.(CompatChecker) + if !ok { + return providers + } + var out []ProviderInfo + for _, prov := range providers { + if providerMatchesProfile(prov, p, checker) { + out = append(out, prov) + } + } + return out +} + +// providerMatchesProfile reports whether the provider's Compatibility map +// has at least one key that matches one of the profile's backends' RequiredCompat keys. +func providerMatchesProfile(prov ProviderInfo, p Profile, checker CompatChecker) bool { + for _, b := range p.SupportedBackends() { + for _, key := range checker.RequiredCompat(b) { + if prov.Compatibility[key] { + return true + } + } + } + return false +} + +// BackendsForProvider returns the backends of a profile that are supported by +// a specific provider. If the profile does not implement CompatChecker, all +// supported backends are returned. +func (m *Manager) BackendsForProvider(p Profile, provider ProviderInfo) []Backend { + checker, ok := p.(CompatChecker) + if !ok { + return p.SupportedBackends() + } + var out []Backend + for _, b := range p.SupportedBackends() { + for _, key := range checker.RequiredCompat(b) { + if provider.Compatibility[key] { + out = append(out, b) + break + } + } + } + return out +} + +// DedupBackends removes backends with identical compat key signatures, +// keeping only the first backend for each unique signature. This avoids +// showing the user multiple backends that are functionally equivalent +// (e.g. Anthropic and ZAI both require only "anthropic_messages"). +func (m *Manager) DedupBackends(p Profile, backends []Backend) []Backend { + checker, ok := p.(CompatChecker) + if !ok { + return backends + } + seen := make(map[string]bool) + var out []Backend + for _, b := range backends { + sig := compatKeySig(checker.RequiredCompat(b)) + if !seen[sig] { + seen[sig] = true + out = append(out, b) + } + } + return out +} + +func compatKeySig(keys []string) string { + return strings.Join(keys, ",") +} + // ValidCombos returns all (profile, backend) combos where the profile binary // is present on PATH. If providers is non-nil, backends are filtered by // provider compatibility. @@ -290,6 +379,8 @@ func (m *Manager) InstalledProfiles() []Profile { type StateFile struct { LastProfileName string `json:"lastProfileName,omitempty"` LastBackendType string `json:"lastBackendType,omitempty"` + LastProviderID string `json:"lastProviderId,omitempty"` + LastModel string `json:"lastModel,omitempty"` } // statePath returns the path to the launcher state JSON file. diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go index acc7e1b..e7d662c 100644 --- a/internal/profiles/profiles_test.go +++ b/internal/profiles/profiles_test.go @@ -773,6 +773,244 @@ func TestLauncher_ClaudeDesktop_SupportedBackends(t *testing.T) { } } +func TestLauncher_CompatibleProviders(t *testing.T) { + mgr := profiles.NewManager() + p := &profiles.ClaudeCodeProfile{} + providers := []profiles.ProviderInfo{ + { + ID: "anthropic", + Name: "Anthropic", + Compatibility: map[string]bool{ + "anthropic_messages": true, + }, + }, + { + ID: "bedrock", + Name: "AWS Bedrock", + Compatibility: map[string]bool{ + "bedrock_model_invoke": true, + }, + }, + { + ID: "openai-only", + Name: "OpenAI Only", + Compatibility: map[string]bool{ + "openai_chat": true, + }, + }, + } + + compatible := mgr.CompatibleProviders(p, providers) + if len(compatible) != 2 { + t.Fatalf("expected 2 compatible providers, got %d", len(compatible)) + } + + gotIDs := make(map[string]bool) + for _, prov := range compatible { + gotIDs[prov.ID] = true + } + if !gotIDs["anthropic"] || !gotIDs["bedrock"] { + t.Errorf("expected anthropic and bedrock, got IDs: %v", compatible) + } +} + +func TestLauncher_CompatibleProviders_NoCompatChecker(t *testing.T) { + // Create a profile that does not implement CompatChecker. + mgr := profiles.NewManager() + p := &noCompatProfile{} + providers := []profiles.ProviderInfo{ + {ID: "a", Name: "A", Compatibility: map[string]bool{"x": true}}, + {ID: "b", Name: "B", Compatibility: map[string]bool{"y": true}}, + } + + compatible := mgr.CompatibleProviders(p, providers) + if len(compatible) != 2 { + t.Errorf("expected all providers returned for non-CompatChecker profile, got %d", len(compatible)) + } +} + +func TestLauncher_CompatibleProviders_NoMatch(t *testing.T) { + mgr := profiles.NewManager() + p := &profiles.ClaudeCodeProfile{} + providers := []profiles.ProviderInfo{ + { + ID: "openai-only", + Name: "OpenAI Only", + Compatibility: map[string]bool{ + "openai_chat": true, + }, + }, + } + + compatible := mgr.CompatibleProviders(p, providers) + if len(compatible) != 0 { + t.Errorf("expected 0 compatible providers, got %d", len(compatible)) + } +} + +func TestLauncher_BackendsForProvider(t *testing.T) { + mgr := profiles.NewManager() + p := &profiles.ClaudeCodeProfile{} + provider := profiles.ProviderInfo{ + ID: "multi", + Name: "Multi", + Compatibility: map[string]bool{ + "anthropic_messages": true, + "bedrock_model_invoke": true, + }, + } + + backends := mgr.BackendsForProvider(p, provider) + // anthropic_messages matches both Anthropic and ZAI backends; + // bedrock_model_invoke matches Bedrock. + if len(backends) != 3 { + t.Fatalf("expected 3 backends, got %d", len(backends)) + } + + gotTypes := make(map[profiles.BackendType]bool) + for _, b := range backends { + gotTypes[b.Type] = true + } + if !gotTypes[profiles.BackendAnthropic] || !gotTypes[profiles.BackendBedrock] || !gotTypes[profiles.BackendZAI] { + t.Errorf("expected anthropic, zai, and bedrock, got: %v", backends) + } +} + +func TestLauncher_BackendsForProvider_SingleBackend(t *testing.T) { + mgr := profiles.NewManager() + p := &profiles.ClaudeCodeProfile{} + provider := profiles.ProviderInfo{ + ID: "bedrock-only", + Name: "Bedrock Only", + Compatibility: map[string]bool{ + "bedrock_model_invoke": true, + }, + } + + backends := mgr.BackendsForProvider(p, provider) + if len(backends) != 1 { + t.Fatalf("expected 1 backend, got %d", len(backends)) + } + if backends[0].Type != profiles.BackendBedrock { + t.Errorf("backend type = %q, want %q", backends[0].Type, profiles.BackendBedrock) + } +} + +func TestLauncher_BackendsForProvider_NoCompatChecker(t *testing.T) { + mgr := profiles.NewManager() + p := &noCompatProfile{} + provider := profiles.ProviderInfo{ + ID: "any", + Name: "Any", + Compatibility: map[string]bool{}, + } + + backends := mgr.BackendsForProvider(p, provider) + if len(backends) != len(p.SupportedBackends()) { + t.Errorf("expected all backends for non-CompatChecker profile, got %d want %d", + len(backends), len(p.SupportedBackends())) + } +} + +func TestLauncher_DedupBackends(t *testing.T) { + mgr := profiles.NewManager() + p := &profiles.ClaudeCodeProfile{} + provider := profiles.ProviderInfo{ + ID: "multi", + Name: "Multi", + Compatibility: map[string]bool{ + "anthropic_messages": true, + "bedrock_model_invoke": true, + }, + } + + backends := mgr.BackendsForProvider(p, provider) + // Before dedup: Anthropic + ZAI (both anthropic_messages) + Bedrock = 3 + if len(backends) != 3 { + t.Fatalf("expected 3 backends before dedup, got %d", len(backends)) + } + + deduped := mgr.DedupBackends(p, backends) + // After dedup: Anthropic (first of the anthropic_messages group) + Bedrock = 2 + if len(deduped) != 2 { + t.Fatalf("expected 2 backends after dedup, got %d", len(deduped)) + } + + gotTypes := make(map[profiles.BackendType]bool) + for _, b := range deduped { + gotTypes[b.Type] = true + } + if !gotTypes[profiles.BackendAnthropic] || !gotTypes[profiles.BackendBedrock] { + t.Errorf("expected Anthropic and Bedrock after dedup, got: %v", deduped) + } + // ZAI should have been deduped away (same compat key as Anthropic). + if gotTypes[profiles.BackendZAI] { + t.Error("ZAI should have been deduplicated since it shares anthropic_messages with Anthropic") + } +} + +func TestLauncher_DedupBackends_NoCompatChecker(t *testing.T) { + mgr := profiles.NewManager() + p := &noCompatProfile{} + backends := p.SupportedBackends() + + deduped := mgr.DedupBackends(p, backends) + if len(deduped) != len(backends) { + t.Errorf("expected no dedup for non-CompatChecker profile, got %d want %d", + len(deduped), len(backends)) + } +} + +func TestLauncher_ClaudeCode_ApplyModel(t *testing.T) { + p := &profiles.ClaudeCodeProfile{} + env := map[string]string{"ANTHROPIC_BASE_URL": "http://ai"} + p.ApplyModel("claude-sonnet-4-20250514", env) + if env["ANTHROPIC_MODEL"] != "claude-sonnet-4-20250514" { + t.Errorf("ANTHROPIC_MODEL = %q, want %q", env["ANTHROPIC_MODEL"], "claude-sonnet-4-20250514") + } +} + +func TestLauncher_StateFile_LastProviderID_RoundTrip(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg == "" { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + } + + want := profiles.StateFile{ + LastProfileName: "Claude Code", + LastBackendType: string(profiles.BackendAnthropic), + LastProviderID: "anthropic-via-aperture", + } + if err := profiles.SaveState(want); err != nil { + t.Fatalf("SaveState: %v", err) + } + + got, err := profiles.LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + + if got.LastProviderID != want.LastProviderID { + t.Errorf("LastProviderID = %q, want %q", got.LastProviderID, want.LastProviderID) + } +} + +// noCompatProfile is a test Profile that does not implement CompatChecker. +type noCompatProfile struct{} + +func (noCompatProfile) Name() string { return "no-compat" } +func (noCompatProfile) BinaryName() string { return "no-compat-binary" } +func (noCompatProfile) SupportedBackends() []profiles.Backend { + return []profiles.Backend{ + {Type: profiles.BackendAnthropic, DisplayName: "Anthropic"}, + {Type: profiles.BackendOpenAI, DisplayName: "OpenAI"}, + } +} +func (noCompatProfile) Env(string, profiles.Backend) (map[string]string, error) { + return nil, nil +} + func TestLauncher_AllProfiles_ImplementPathHinter(t *testing.T) { mgr := profiles.NewManager() for _, p := range mgr.AllProfiles() { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c0dab91..983bba5 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -20,8 +20,10 @@ type step int const ( stepPreflight step = iota // checking /api/providers - stepSelectAgent // choose profile - stepSelectBackend // choose provider + stepSelectProfile // choose profile + stepSelectProvider // choose provider for the selected profile + stepSelectBackend // choose backend (only when genuinely different compat keys) + stepSelectModel // choose default model from provider's model list stepSettings // top-level settings menu stepEndpoints // manage aperture endpoints stepAddLocation // type a new endpoint URL @@ -65,12 +67,23 @@ type model struct { allProfiles []profiles.Profile installedProfiles []profiles.Profile - step step - agentCursor int - backendItems []profiles.Backend - backendCursor int + step step + profileCursor int + backendItems []profiles.Backend + backendCursor int - chosenProfile profiles.Profile + chosenProfile profiles.Profile + chosenProvider profiles.ProviderInfo + chosenBackend profiles.Backend + + // provider selection step + providerItems []profiles.ProviderInfo + providerCursor int + + // model selection step + modelItems []string + modelCursor int + selectedModel string // preflight state preflightChecking bool @@ -206,12 +219,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lastCombo = nil } } - // If exactly one combo exists, jump straight to exec. - combos := m.manager.ValidCombos(m.providers) - if len(combos) == 1 { - return m, func() tea.Msg { return autoSelectMsg{combo: combos[0]} } + // Auto-select only when there's a single unambiguous path through + // profile → provider → backend. + if autoCombo, ok := m.tryAutoSelect(); ok { + return m, func() tea.Msg { return autoSelectMsg{combo: autoCombo} } } - m.step = stepSelectAgent + m.step = stepSelectProfile return m, tea.ClearScreen case autoSelectMsg: @@ -220,8 +233,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case installDoneMsg: // Re-check installed CLIs after the install command finishes. m.installedProfiles = m.manager.InstalledProfiles() - m.step = stepSelectAgent - m.agentCursor = 0 + m.step = stepSelectProfile + m.profileCursor = 0 return m, tea.ClearScreen case uninstallDoneMsg: @@ -256,11 +269,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Re-run preflight after agent exits. m.step = stepPreflight m.preflightChecking = true - m.agentCursor = 0 + m.profileCursor = 0 return m, runPreflight(m.apertureHost) case launchDoneMsg: - // Desktop app launched (returns immediately). Go back to the agent + // Desktop app launched (returns immediately). Go back to the profile // selection screen without re-running preflight to avoid an // auto-select loop. if msg.err != nil { @@ -268,8 +281,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.step = stepError return m, nil } - m.step = stepSelectAgent - m.agentCursor = 0 + m.step = stepSelectProfile + m.profileCursor = 0 return m, tea.ClearScreen case tea.KeyMsg: @@ -282,12 +295,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case stepError: return m, tea.Quit - case stepSelectAgent: - return m.updateSelectAgent(msg) + case stepSelectProfile: + return m.updateSelectProfile(msg) + + case stepSelectProvider: + return m.updateSelectProvider(msg) case stepSelectBackend: return m.updateSelectBackend(msg) + case stepSelectModel: + return m.updateSelectModel(msg) + case stepSettings: return m.updateSettings(msg) @@ -314,13 +333,39 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+c", "q": return m, tea.Quit case "esc": - m.step = stepSelectBackend + m.step = stepSelectProvider } } } return m, nil } +// tryAutoSelect returns a Combo and true when there is exactly one installed +// profile, one compatible provider, and one deduped backend for that provider, +// and either the provider has 0-1 models or the profile doesn't support model +// selection. This is the only case where we skip the menu entirely. +func (m model) tryAutoSelect() (profiles.Combo, bool) { + if len(m.installedProfiles) != 1 { + return profiles.Combo{}, false + } + p := m.installedProfiles[0] + providers := m.manager.CompatibleProviders(p, m.providers) + if len(providers) != 1 { + return profiles.Combo{}, false + } + backends := m.manager.BackendsForProvider(p, providers[0]) + backends = m.manager.DedupBackends(p, backends) + if len(backends) != 1 { + return profiles.Combo{}, false + } + // If the profile supports model selection and the provider has multiple + // models, don't auto-select — the user needs to pick a model. + if _, ok := p.(profiles.ModelSelector); ok && len(providers[0].Models) > 1 { + return profiles.Combo{}, false + } + return profiles.Combo{Profile: p, Backend: backends[0]}, true +} + // isInstalled reports whether a profile's binary is currently on PATH, // using the cached installedProfiles slice. func (m model) isInstalled(p profiles.Profile) bool { @@ -343,19 +388,8 @@ func (m model) uninstalledProfiles() []profiles.Profile { return result } -func (m model) updateSelectAgent(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // rows: (last-used if exists) + installed profiles + (Install agents if uninstalled exist) + Settings - hasLast := m.lastCombo != nil +func (m model) updateSelectProfile(msg tea.KeyMsg) (tea.Model, tea.Cmd) { profileCount := len(m.installedProfiles) - hasUninstalled := len(m.uninstalledProfiles()) > 0 - // +1 for the Settings row - totalRows := profileCount + 1 - if hasLast { - totalRows++ - } - if hasUninstalled { - totalRows++ - } switch msg.String() { case "ctrl+c", "q": @@ -367,107 +401,162 @@ func (m model) updateSelectAgent(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "i": - if hasUninstalled { + if len(m.uninstalledProfiles()) > 0 { m.step = stepInstallAgents m.installAgentsCursor = 0 return m, nil } case "up", "k": - if m.agentCursor > 0 { - m.agentCursor-- + if m.profileCursor > 0 { + m.profileCursor-- } case "down", "j": - if m.agentCursor < totalRows-1 { - m.agentCursor++ + if m.profileCursor < profileCount-1 { + m.profileCursor++ } case "enter": - return m.confirmAgentSelection() + return m.confirmProfileSelection() default: n, err := strconv.Atoi(msg.String()) if err == nil { - if hasLast && n == 0 { + // [0] re-launches last-used combo. + if n == 0 && m.lastCombo != nil { combo := *m.lastCombo return m, m.execCombo(combo) } + // [1..N] selects a profile directly. idx := n - 1 if idx >= 0 && idx < profileCount { - m.agentCursor = idx - if hasLast { - m.agentCursor = n - } - return m.confirmAgentSelection() + m.profileCursor = idx + return m.confirmProfileSelection() } } } return m, nil } -// confirmAgentSelection resolves which row was picked and transitions. -func (m model) confirmAgentSelection() (model, tea.Cmd) { - hasLast := m.lastCombo != nil - hasUninstalled := len(m.uninstalledProfiles()) > 0 - - if hasLast && m.agentCursor == 0 { - combo := *m.lastCombo - return m, m.execCombo(combo) +// confirmProfileSelection resolves the chosen profile and transitions to +// provider selection or auto-launches if only one provider is compatible. +func (m model) confirmProfileSelection() (model, tea.Cmd) { + if m.profileCursor < 0 || m.profileCursor >= len(m.installedProfiles) { + return m, nil } - - profileIdx := m.agentCursor - if hasLast { - profileIdx = m.agentCursor - 1 + chosen := m.installedProfiles[m.profileCursor] + m.chosenProfile = chosen + m.selectedModel = "" + + m.providerItems = m.manager.CompatibleProviders(chosen, m.providers) + if len(m.providerItems) == 0 { + m.err = fmt.Sprintf("No compatible providers for %s.", chosen.Name()) + m.step = stepCheckError + return m, nil } + if len(m.providerItems) == 1 { + m.chosenProvider = m.providerItems[0] + return m.resolveProviderAndExec() + } + m.providerCursor = 0 + m.step = stepSelectProvider + return m, nil +} - // Installed profile selected. - if profileIdx >= 0 && profileIdx < len(m.installedProfiles) { - chosen := m.installedProfiles[profileIdx] - m.chosenProfile = chosen - - m.backendItems = m.manager.FilteredBackends(chosen, m.providers) - if len(m.backendItems) == 0 { - m.err = fmt.Sprintf("No compatible providers for %s.", chosen.Name()) - m.step = stepCheckError - return m, nil +func (m model) updateSelectProvider(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc": + m.step = stepSelectProfile + return m, tea.ClearScreen + case "up", "k": + if m.providerCursor > 0 { + m.providerCursor-- } - if len(m.backendItems) == 1 { - b := m.backendItems[0] - if checker, ok := chosen.(profiles.Checker); ok { - if err := checker.Check(b); err != nil { - m.err = err.Error() - m.step = stepCheckError - return m, nil - } + case "down", "j": + if m.providerCursor < len(m.providerItems)-1 { + m.providerCursor++ + } + case "enter": + return m.confirmProviderSelection() + default: + n, err := strconv.Atoi(msg.String()) + if err == nil { + idx := n - 1 + if idx >= 0 && idx < len(m.providerItems) { + m.providerCursor = idx + return m.confirmProviderSelection() } - combo := profiles.Combo{Profile: chosen, Backend: b} - return m, m.execCombo(combo) } - m.backendCursor = 0 - m.step = stepSelectBackend + } + return m, nil +} + +// confirmProviderSelection resolves the chosen provider and either auto-launches +// (single backend) or shows the backend submenu. +func (m model) confirmProviderSelection() (model, tea.Cmd) { + if m.providerCursor < 0 || m.providerCursor >= len(m.providerItems) { return m, nil } + m.chosenProvider = m.providerItems[m.providerCursor] + return m.resolveProviderAndExec() +} - // "Install agents" row (right after installed profiles). - nextIdx := len(m.installedProfiles) - if hasUninstalled && profileIdx == nextIdx { - m.step = stepInstallAgents - m.installAgentsCursor = 0 +// resolveProviderAndExec checks how many backends the chosen provider supports +// for the chosen profile, then either auto-launches, shows a model picker, or +// shows the backend submenu. +func (m model) resolveProviderAndExec() (model, tea.Cmd) { + backends := m.manager.BackendsForProvider(m.chosenProfile, m.chosenProvider) + if len(backends) == 0 { + m.err = fmt.Sprintf("No compatible backends for %s with %s.", + m.chosenProfile.Name(), m.chosenProvider.DisplayName()) + m.step = stepCheckError return m, nil } - if hasUninstalled { - nextIdx++ + + // Deduplicate backends that share the same compat key signature + // (e.g. Anthropic and ZAI both use "anthropic_messages"). + backends = m.manager.DedupBackends(m.chosenProfile, backends) + + if len(backends) == 1 { + return m.proceedWithBackend(backends[0]) } + // Multiple genuinely-different backends (e.g. Anthropic vs Bedrock). + m.backendItems = backends + m.backendCursor = 0 + m.step = stepSelectBackend + return m, nil +} - // Settings row. - if profileIdx == nextIdx { - m.step = stepSettings - m.settingsCursor = 0 +// proceedWithBackend resolves the model selection for a single backend and +// either auto-launches or shows the model picker. +func (m model) proceedWithBackend(b profiles.Backend) (model, tea.Cmd) { + _, wantsModel := m.chosenProfile.(profiles.ModelSelector) + + if wantsModel && len(m.chosenProvider.Models) > 1 { + m.chosenBackend = b + m.modelItems = fqnModels(m.chosenProvider) + m.modelCursor = 0 + m.step = stepSelectModel return m, nil } - return m, nil + // Auto-select the single model if available. + if len(m.chosenProvider.Models) == 1 { + m.selectedModel = m.chosenProvider.ID + "/" + m.chosenProvider.Models[0] + } + + if checker, ok := m.chosenProfile.(profiles.Checker); ok { + if err := checker.Check(b); err != nil { + m.err = err.Error() + m.step = stepCheckError + return m, nil + } + } + combo := profiles.Combo{Profile: m.chosenProfile, Backend: b} + return m, m.execCombo(combo) } func (m model) updateInstall(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -475,7 +564,7 @@ func (m model) updateInstall(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+c", "q": return m, tea.Quit case "esc", "n", "enter": - m.step = stepSelectAgent + m.step = stepSelectProfile return m, tea.ClearScreen case "y": return m, m.runInstall() @@ -491,7 +580,7 @@ func (m model) updateInstallAgents(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+c", "q": return m, tea.Quit case "esc": - m.step = stepSelectAgent + m.step = stepSelectProfile return m, tea.ClearScreen case "up", "k": if m.installAgentsCursor > 0 { @@ -613,7 +702,7 @@ func (m model) updateSelectBackend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+c", "q": return m, tea.Quit case "esc": - m.step = stepSelectAgent + m.step = stepSelectProvider return m, tea.ClearScreen case "up", "k": @@ -641,6 +730,54 @@ func (m model) updateSelectBackend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m model) updateSelectModel(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "esc": + m.step = stepSelectProvider + return m, tea.ClearScreen + case "up", "k": + if m.modelCursor > 0 { + m.modelCursor-- + } + case "down", "j": + if m.modelCursor < len(m.modelItems)-1 { + m.modelCursor++ + } + case "enter": + return m.confirmModelSelection() + default: + n, err := strconv.Atoi(msg.String()) + if err == nil { + idx := n - 1 + if idx >= 0 && idx < len(m.modelItems) { + m.modelCursor = idx + return m.confirmModelSelection() + } + } + } + return m, nil +} + +func (m model) confirmModelSelection() (model, tea.Cmd) { + if m.modelCursor < 0 || m.modelCursor >= len(m.modelItems) { + return m, nil + } + m.selectedModel = m.modelItems[m.modelCursor] + + b := m.chosenBackend + if checker, ok := m.chosenProfile.(profiles.Checker); ok { + if err := checker.Check(b); err != nil { + m.err = err.Error() + m.step = stepCheckError + return m, nil + } + } + combo := profiles.Combo{Profile: m.chosenProfile, Backend: b} + return m, m.execCombo(combo) +} + // settingsRows returns the rows for the top-level settings menu. // Row layout: "Aperture Endpoints" + "Uninstall" + "YOLO mode". func (m model) settingsRows() []string { @@ -663,7 +800,7 @@ func (m model) updateSettings(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit case "esc", "q": - m.step = stepSelectAgent + m.step = stepSelectProfile return m, tea.ClearScreen case "up", "k": @@ -834,6 +971,15 @@ func (m model) updateAddLocation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // upsertLocation ensures loc is in settings.Endpoints without duplicates. // If it already exists it stays in place; otherwise it is appended. +// fqnModels returns fully qualified model names in the form "provider_id/model_id". +func fqnModels(p profiles.ProviderInfo) []string { + out := make([]string, len(p.Models)) + for i, m := range p.Models { + out[i] = p.ID + "/" + m + } + return out +} + func upsertLocation(s profiles.Settings, loc string) profiles.Settings { for _, ep := range s.Endpoints { if ep.URL == loc { @@ -849,6 +995,18 @@ func (m model) checkAndExecSelectedBackend() (model, tea.Cmd) { return m, nil } b := m.backendItems[m.backendCursor] + + // If the profile supports model selection and the provider has multiple + // models, show the model picker instead of launching immediately. + _, wantsModel := m.chosenProfile.(profiles.ModelSelector) + if wantsModel && len(m.chosenProvider.Models) > 1 { + m.chosenBackend = b + m.modelItems = fqnModels(m.chosenProvider) + m.modelCursor = 0 + m.step = stepSelectModel + return m, nil + } + if checker, ok := m.chosenProfile.(profiles.Checker); ok { if err := checker.Check(b); err != nil { m.err = err.Error() @@ -867,6 +1025,8 @@ func (m model) execCombo(combo profiles.Combo) tea.Cmd { _ = profiles.SaveState(profiles.StateFile{ LastProfileName: combo.Profile.Name(), LastBackendType: string(combo.Backend.Type), + LastProviderID: m.chosenProvider.ID, + LastModel: m.selectedModel, }) host := m.apertureHost return func() tea.Msg { @@ -880,11 +1040,17 @@ func (m model) execCombo(combo profiles.Combo) tea.Cmd { } if ps, ok := combo.Profile.(profiles.ProviderEnvSetter); ok { - for k, v := range ps.ProviderEnv(combo.Backend, m.providers) { + for k, v := range ps.ProviderEnv(combo.Backend, []profiles.ProviderInfo{m.chosenProvider}) { env[k] = v } } + if m.selectedModel != "" { + if ms, ok := combo.Profile.(profiles.ModelSelector); ok { + ms.ApplyModel(m.selectedModel, env) + } + } + binary := profiles.FindBinary(combo.Profile) if binary == "" { binary = combo.Profile.BinaryName() @@ -893,6 +1059,8 @@ func (m model) execCombo(combo profiles.Combo) tea.Cmd { _ = profiles.SaveState(profiles.StateFile{ LastProfileName: combo.Profile.Name(), LastBackendType: string(combo.Backend.Type), + LastProviderID: m.chosenProvider.ID, + LastModel: m.selectedModel, }) envPairs := os.Environ() @@ -966,7 +1134,7 @@ func (m model) View() string { sb.WriteString(errorStyle.Render("Error: " + m.err)) sb.WriteString("\n\nPress any key to exit.\n") - case stepSelectAgent: + case stepSelectProfile: sb.WriteString(dotGreen + " Connected to " + m.apertureHost) if len(m.providers) > 0 { sb.WriteString(fmt.Sprintf(" (%d providers)", len(m.providers))) @@ -975,34 +1143,10 @@ func (m model) View() string { sb.WriteString(titleStyle.Render("Which editor do you want to use?")) sb.WriteString("\n") - hasLast := m.lastCombo != nil - hasUninstalled := len(m.uninstalledProfiles()) > 0 - - if hasLast { - label := fmt.Sprintf(" [0] Last Used: %s - %s", - m.lastCombo.Profile.Name(), m.lastCombo.Backend.DisplayName) - if m.agentCursor == 0 { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - } - for i, p := range m.installedProfiles { n := i + 1 - cursor := i - if hasLast { - cursor = i + 1 - } - backends := m.manager.FilteredBackends(p, m.providers) - var label string - if len(backends) == 1 { - label = fmt.Sprintf(" [%d] %s - %s", n, p.Name(), backends[0].DisplayName) - } else { - label = fmt.Sprintf(" [%d] %s", n, p.Name()) - } - if m.agentCursor == cursor { + label := fmt.Sprintf(" [%d] %s", n, p.Name()) + if m.profileCursor == i { sb.WriteString(selectedStyle.Render(label)) } else { sb.WriteString(label) @@ -1012,40 +1156,45 @@ func (m model) View() string { sb.WriteString("\n") - // "Install agents" row — only if uninstalled profiles exist - nextCursor := len(m.installedProfiles) - if hasLast { - nextCursor++ - } - if hasUninstalled { - installLabel := " [i] Install agents" - if m.agentCursor == nextCursor { - sb.WriteString(selectedStyle.Render(installLabel)) - } else { - sb.WriteString(installLabel) - } + // Last-used shortcut (non-selectable hint). + if m.lastCombo != nil { + sb.WriteString(dimStyle.Render(fmt.Sprintf(" [0] Re-launch last: %s - %s", + m.lastCombo.Profile.Name(), m.lastCombo.Backend.DisplayName))) sb.WriteString("\n") - nextCursor++ } - // Settings row - settingsLabel := " [s] Settings" - if m.agentCursor == nextCursor { - sb.WriteString(selectedStyle.Render(settingsLabel)) - } else { - sb.WriteString(settingsLabel) + // Keyboard shortcut hints. + hints := []string{"[s] Settings"} + if len(m.uninstalledProfiles()) > 0 { + hints = append(hints, "[i] Install agents") } + hints = append(hints, "[q] Quit") + sb.WriteString(dimStyle.Render(" " + strings.Join(hints, " "))) sb.WriteString("\n") - sb.WriteString(" [q] Quit") + sb.WriteString("\n") + sb.WriteString(dimStyle.Render("Selection: ")) + case stepSelectProvider: + sb.WriteString(titleStyle.Render(fmt.Sprintf("Choose a provider for %s:", m.chosenProfile.Name()))) sb.WriteString("\n") - if hasLast { - sb.WriteString(dimStyle.Render("Selection (default: 0): ")) - } else { - sb.WriteString(dimStyle.Render("Selection: ")) + + for i, prov := range m.providerItems { + label := fmt.Sprintf(" [%d] %s", i+1, prov.DisplayName()) + if prov.Description != "" { + label += " " + dimStyle.Render(prov.Description) + } + if m.providerCursor == i { + sb.WriteString(selectedStyle.Render(label)) + } else { + sb.WriteString(label) + } + sb.WriteString("\n") } + sb.WriteString("\n") + sb.WriteString(dimStyle.Render("Selection: ")) + case stepInstallAgents: sb.WriteString(titleStyle.Render("Install agents")) sb.WriteString("\n") @@ -1067,7 +1216,8 @@ func (m model) View() string { sb.WriteString(dimStyle.Render("Enter to select · Esc to go back\n")) case stepSelectBackend: - sb.WriteString(titleStyle.Render("Choose a Provider:")) + sb.WriteString(titleStyle.Render(fmt.Sprintf("Choose a backend for %s via %s:", + m.chosenProfile.Name(), m.chosenProvider.DisplayName()))) sb.WriteString("\n") for i, b := range m.backendItems { @@ -1083,6 +1233,24 @@ func (m model) View() string { sb.WriteString("\n") sb.WriteString(dimStyle.Render("Selection: ")) + case stepSelectModel: + sb.WriteString(titleStyle.Render(fmt.Sprintf("Choose a default model for %s via %s:", + m.chosenProfile.Name(), m.chosenProvider.DisplayName()))) + sb.WriteString("\n") + + for i, model := range m.modelItems { + label := fmt.Sprintf(" [%d] %s", i+1, model) + if m.modelCursor == i { + sb.WriteString(selectedStyle.Render(label)) + } else { + sb.WriteString(label) + } + sb.WriteString("\n") + } + + sb.WriteString("\n") + sb.WriteString(dimStyle.Render("Selection: ")) + case stepSettings: sb.WriteString(titleStyle.Render("Settings")) sb.WriteString("\n") From fdaaabea4d7007c097942aa7540753e28b87ff6e Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Wed, 29 Apr 2026 23:31:49 +0000 Subject: [PATCH 02/15] internal/profiles: fix Codex model selection and HTTPS transport CodexProfile was missing the ModelSelector interface, so the TUI skipped the model picker. WriteConfig also didn't set the API base URL in config.toml, causing Codex to connect to api.openai.com instead of the aperture proxy, and defaulted to WebSocket transport. - Add ApplyModel to implement ModelSelector for model picker support - Write config.toml with a custom "aperture" provider that sets base_url to the aperture host and forces HTTPS transport via supports_websockets = false --- internal/profiles/codex.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/profiles/codex.go b/internal/profiles/codex.go index 800c872..e3272e1 100644 --- a/internal/profiles/codex.go +++ b/internal/profiles/codex.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" ) // CodexProfile implements Profile for the OpenAI `codex` CLI tool. @@ -58,6 +59,10 @@ func (c *CodexProfile) RequiredCompat(b Backend) []string { } } +func (c *CodexProfile) ApplyModel(model string, env map[string]string) { + env["OPENAI_MODEL"] = model +} + func (c *CodexProfile) Env(apertureHost string, b Backend) (map[string]string, error) { switch b.Type { case BackendOpenAI: @@ -72,7 +77,7 @@ func (c *CodexProfile) Env(apertureHost string, b Backend) (map[string]string, e // WriteConfig creates a persistent CODEX_HOME with auth.json pre-populated // so Codex does not prompt for interactive login on first run. -func (c *CodexProfile) WriteConfig(_ string, _ Backend) (envKey, configPath string, cleanup func(), err error) { +func (c *CodexProfile) WriteConfig(apertureHost string, _ Backend) (envKey, configPath string, cleanup func(), err error) { cfgDir, err := os.UserConfigDir() if err != nil { return "", "", nil, err @@ -93,5 +98,17 @@ func (c *CodexProfile) WriteConfig(_ string, _ Backend) (envKey, configPath stri if err := os.WriteFile(filepath.Join(codexHome, "auth.json"), data, 0o600); err != nil { return "", "", nil, err } + + baseURL := apertureHost + "/v1" + cfg := "model_provider = \"aperture\"\n\n" + + "[model_providers.aperture]\n" + + "name = \"Aperture\"\n" + + "base_url = " + strconv.Quote(baseURL) + "\n" + + "env_key = \"OPENAI_API_KEY\"\n" + + "supports_websockets = false\n" + if err := os.WriteFile(filepath.Join(codexHome, "config.toml"), []byte(cfg), 0o600); err != nil { + return "", "", nil, err + } + return "CODEX_HOME", codexHome, func() {}, nil } From d8cf56dfdbcd8b3b7773741092588c00852f1470 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Wed, 29 Apr 2026 23:41:57 +0000 Subject: [PATCH 03/15] Add quick select for last used model --- internal/profiles/profiles_test.go | 4 + internal/tui/tui.go | 266 ++++++++++++++++++++--------- internal/tui/tui_test.go | 107 ++++++++++++ 3 files changed, 301 insertions(+), 76 deletions(-) create mode 100644 internal/tui/tui_test.go diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go index e7d662c..ae73375 100644 --- a/internal/profiles/profiles_test.go +++ b/internal/profiles/profiles_test.go @@ -981,6 +981,7 @@ func TestLauncher_StateFile_LastProviderID_RoundTrip(t *testing.T) { LastProfileName: "Claude Code", LastBackendType: string(profiles.BackendAnthropic), LastProviderID: "anthropic-via-aperture", + LastModel: "anthropic-via-aperture/claude-sonnet", } if err := profiles.SaveState(want); err != nil { t.Fatalf("SaveState: %v", err) @@ -994,6 +995,9 @@ func TestLauncher_StateFile_LastProviderID_RoundTrip(t *testing.T) { if got.LastProviderID != want.LastProviderID { t.Errorf("LastProviderID = %q, want %q", got.LastProviderID, want.LastProviderID) } + if got.LastModel != want.LastModel { + t.Errorf("LastModel = %q, want %q", got.LastModel, want.LastModel) + } } // noCompatProfile is a test Profile that does not implement CompatChecker. diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 983bba5..ce17290 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -54,23 +54,29 @@ type preflightResult struct { err error } +type resolvedSelection struct { + combo profiles.Combo + provider profiles.ProviderInfo + selectedModel string +} + type model struct { apertureHost string settings profiles.Settings state profiles.StateFile manager *profiles.Manager - // resolved combos for the last-used shortcut - lastCombo *profiles.Combo + // resolved selection for the last-used shortcut + lastSelection *resolvedSelection // all known profiles; installedProfiles is the subset on PATH allProfiles []profiles.Profile installedProfiles []profiles.Profile - step step - profileCursor int - backendItems []profiles.Backend - backendCursor int + step step + profileCursor int + backendItems []profiles.Backend + backendCursor int chosenProfile profiles.Profile chosenProvider profiles.ProviderInfo @@ -81,8 +87,8 @@ type model struct { providerCursor int // model selection step - modelItems []string - modelCursor int + modelItems []string + modelCursor int selectedModel string // preflight state @@ -128,20 +134,6 @@ func NewModel(apertureHost string, settings profiles.Settings, state profiles.St preflightChecking: true, } - // Resolve last-used combo (only from installed profiles). - if state.LastProfileName != "" && state.LastBackendType != "" { - for _, p := range m.installedProfiles { - if p.Name() == state.LastProfileName { - for _, b := range p.SupportedBackends() { - if string(b.Type) == state.LastBackendType { - combo := profiles.Combo{Profile: p, Backend: b} - m.lastCombo = &combo - } - } - } - } - } - return m } @@ -180,7 +172,7 @@ func runPreflight(host string) tea.Cmd { } } -type autoSelectMsg struct{ combo profiles.Combo } +type autoSelectMsg struct{ selection resolvedSelection } type execDoneMsg struct{ err error } type launchDoneMsg struct{ err error } type installDoneMsg struct{ err error } @@ -205,36 +197,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = profiles.SaveSettings(m.settings) // Re-check which CLIs are installed now. m.installedProfiles = m.manager.InstalledProfiles() - // Validate lastCombo against filtered backends. - if m.lastCombo != nil { - filtered := m.manager.FilteredBackends(m.lastCombo.Profile, m.providers) - found := false - for _, b := range filtered { - if b.Type == m.lastCombo.Backend.Type { - found = true - break - } - } - if !found { - m.lastCombo = nil - } - } + m.refreshLastSelection() + m.resetProfileCursor() // Auto-select only when there's a single unambiguous path through // profile → provider → backend. - if autoCombo, ok := m.tryAutoSelect(); ok { - return m, func() tea.Msg { return autoSelectMsg{combo: autoCombo} } + if selection, ok := m.tryAutoSelect(); ok { + return m, func() tea.Msg { return autoSelectMsg{selection: selection} } } m.step = stepSelectProfile return m, tea.ClearScreen case autoSelectMsg: - return m, m.execCombo(msg.combo) + return m, m.execSelection(msg.selection) case installDoneMsg: // Re-check installed CLIs after the install command finishes. m.installedProfiles = m.manager.InstalledProfiles() m.step = stepSelectProfile - m.profileCursor = 0 + m.refreshLastSelection() + m.resetProfileCursor() return m, tea.ClearScreen case uninstallDoneMsg: @@ -248,24 +229,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reload state from disk to reflect the last-used profile that just exited. state, _ := profiles.LoadState() m.state = state - m.lastCombo = nil + m.lastSelection = nil // Re-check installed CLIs in case something changed while the agent ran. m.installedProfiles = m.manager.InstalledProfiles() - // Re-resolve the last-used combo from updated state. - if state.LastProfileName != "" && state.LastBackendType != "" { - for _, p := range m.installedProfiles { - if p.Name() == state.LastProfileName { - for _, b := range p.SupportedBackends() { - if string(b.Type) == state.LastBackendType { - combo := profiles.Combo{Profile: p, Backend: b} - m.lastCombo = &combo - } - } - } - } - } - // Re-run preflight after agent exits. m.step = stepPreflight m.preflightChecking = true @@ -281,8 +248,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.step = stepError return m, nil } + state, _ := profiles.LoadState() + m.state = state + m.installedProfiles = m.manager.InstalledProfiles() + m.refreshLastSelection() m.step = stepSelectProfile - m.profileCursor = 0 + m.resetProfileCursor() return m, tea.ClearScreen case tea.KeyMsg: @@ -340,30 +311,38 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// tryAutoSelect returns a Combo and true when there is exactly one installed +// tryAutoSelect returns a resolved selection and true when there is exactly one installed // profile, one compatible provider, and one deduped backend for that provider, // and either the provider has 0-1 models or the profile doesn't support model // selection. This is the only case where we skip the menu entirely. -func (m model) tryAutoSelect() (profiles.Combo, bool) { +func (m model) tryAutoSelect() (resolvedSelection, bool) { if len(m.installedProfiles) != 1 { - return profiles.Combo{}, false + return resolvedSelection{}, false } p := m.installedProfiles[0] providers := m.manager.CompatibleProviders(p, m.providers) if len(providers) != 1 { - return profiles.Combo{}, false + return resolvedSelection{}, false } backends := m.manager.BackendsForProvider(p, providers[0]) backends = m.manager.DedupBackends(p, backends) if len(backends) != 1 { - return profiles.Combo{}, false + return resolvedSelection{}, false } // If the profile supports model selection and the provider has multiple // models, don't auto-select — the user needs to pick a model. if _, ok := p.(profiles.ModelSelector); ok && len(providers[0].Models) > 1 { - return profiles.Combo{}, false + return resolvedSelection{}, false + } + selectedModel := "" + if len(providers[0].Models) == 1 { + selectedModel = providers[0].ID + "/" + providers[0].Models[0] } - return profiles.Combo{Profile: p, Backend: backends[0]}, true + return resolvedSelection{ + combo: profiles.Combo{Profile: p, Backend: backends[0]}, + provider: providers[0], + selectedModel: selectedModel, + }, true } // isInstalled reports whether a profile's binary is currently on PATH, @@ -377,6 +356,14 @@ func (m model) isInstalled(p profiles.Profile) bool { return false } +func (m *model) resetProfileCursor() { + if m.lastSelection != nil { + m.profileCursor = -1 + return + } + m.profileCursor = 0 +} + // uninstalledProfiles returns profiles that are not currently installed. func (m model) uninstalledProfiles() []profiles.Profile { var result []profiles.Profile @@ -390,6 +377,10 @@ func (m model) uninstalledProfiles() []profiles.Profile { func (m model) updateSelectProfile(msg tea.KeyMsg) (tea.Model, tea.Cmd) { profileCount := len(m.installedProfiles) + minCursor := 0 + if m.lastSelection != nil { + minCursor = -1 + } switch msg.String() { case "ctrl+c", "q": @@ -408,7 +399,7 @@ func (m model) updateSelectProfile(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "up", "k": - if m.profileCursor > 0 { + if m.profileCursor > minCursor { m.profileCursor-- } @@ -418,15 +409,17 @@ func (m model) updateSelectProfile(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "enter": + if m.profileCursor == -1 && m.lastSelection != nil { + return m, m.execSelection(*m.lastSelection) + } return m.confirmProfileSelection() default: n, err := strconv.Atoi(msg.String()) if err == nil { - // [0] re-launches last-used combo. - if n == 0 && m.lastCombo != nil { - combo := *m.lastCombo - return m, m.execCombo(combo) + // [0] launches the last-used profile/provider/model selection. + if n == 0 && m.lastSelection != nil { + return m, m.execSelection(*m.lastSelection) } // [1..N] selects a profile directly. idx := n - 1 @@ -980,6 +973,111 @@ func fqnModels(p profiles.ProviderInfo) []string { return out } +func containsString(items []string, item string) bool { + for _, v := range items { + if v == item { + return true + } + } + return false +} + +func (m *model) refreshLastSelection() { + m.lastSelection = nil + + if m.state.LastProfileName == "" || m.state.LastBackendType == "" { + return + } + + var selectedProfile profiles.Profile + var selectedBackend profiles.Backend + for _, p := range m.installedProfiles { + if p.Name() != m.state.LastProfileName { + continue + } + for _, b := range p.SupportedBackends() { + if string(b.Type) == m.state.LastBackendType { + selectedProfile = p + selectedBackend = b + break + } + } + break + } + if selectedProfile == nil { + return + } + + provider, ok := m.resolveLastProvider(selectedProfile, selectedBackend) + if !ok { + return + } + + selectedModel, ok := m.resolveLastModel(selectedProfile, provider) + if !ok { + return + } + + m.lastSelection = &resolvedSelection{ + combo: profiles.Combo{ + Profile: selectedProfile, + Backend: selectedBackend, + }, + provider: provider, + selectedModel: selectedModel, + } +} + +func (m model) resolveLastProvider(p profiles.Profile, b profiles.Backend) (profiles.ProviderInfo, bool) { + var candidates []profiles.ProviderInfo + for _, provider := range m.manager.CompatibleProviders(p, m.providers) { + for _, supportedBackend := range m.manager.BackendsForProvider(p, provider) { + if supportedBackend.Type == b.Type { + candidates = append(candidates, provider) + break + } + } + } + if len(candidates) == 0 { + return profiles.ProviderInfo{}, false + } + + if m.state.LastProviderID != "" { + for _, provider := range candidates { + if provider.ID == m.state.LastProviderID { + return provider, true + } + } + return profiles.ProviderInfo{}, false + } + + if len(candidates) == 1 { + return candidates[0], true + } + return profiles.ProviderInfo{}, false +} + +func (m model) resolveLastModel(p profiles.Profile, provider profiles.ProviderInfo) (string, bool) { + if _, ok := p.(profiles.ModelSelector); !ok { + return "", true + } + + models := fqnModels(provider) + if len(models) == 0 { + return "", true + } + + if m.state.LastModel != "" && containsString(models, m.state.LastModel) { + return m.state.LastModel, true + } + + if len(models) == 1 { + return models[0], true + } + + return "", false +} + func upsertLocation(s profiles.Settings, loc string) profiles.Settings { for _, ep := range s.Endpoints { if ep.URL == loc { @@ -1018,6 +1116,12 @@ func (m model) checkAndExecSelectedBackend() (model, tea.Cmd) { return m, m.execCombo(combo) } +func (m model) execSelection(selection resolvedSelection) tea.Cmd { + m.chosenProvider = selection.provider + m.selectedModel = selection.selectedModel + return m.execCombo(selection.combo) +} + func (m model) execCombo(combo profiles.Combo) tea.Cmd { // Desktop app profiles update config if needed and launch the app. // The launch returns immediately (unlike CLI profiles which block). @@ -1143,6 +1247,23 @@ func (m model) View() string { sb.WriteString(titleStyle.Render("Which editor do you want to use?")) sb.WriteString("\n") + if m.lastSelection != nil { + label := fmt.Sprintf(" [0] Quick select: %s via %s - %s", + m.lastSelection.combo.Profile.Name(), + m.lastSelection.provider.DisplayName(), + m.lastSelection.combo.Backend.DisplayName) + if m.lastSelection.selectedModel != "" { + label += " - " + m.lastSelection.selectedModel + } + if m.profileCursor == -1 { + sb.WriteString(selectedStyle.Render(label)) + } else { + sb.WriteString(label) + } + sb.WriteString("\n") + sb.WriteString("\n") + } + for i, p := range m.installedProfiles { n := i + 1 label := fmt.Sprintf(" [%d] %s", n, p.Name()) @@ -1156,13 +1277,6 @@ func (m model) View() string { sb.WriteString("\n") - // Last-used shortcut (non-selectable hint). - if m.lastCombo != nil { - sb.WriteString(dimStyle.Render(fmt.Sprintf(" [0] Re-launch last: %s - %s", - m.lastCombo.Profile.Name(), m.lastCombo.Backend.DisplayName))) - sb.WriteString("\n") - } - // Keyboard shortcut hints. hints := []string{"[s] Settings"} if len(m.uninstalledProfiles()) > 0 { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..c3dadba --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,107 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/profiles" +) + +type quickTestProfile struct{} + +func (quickTestProfile) Name() string { return "Test Agent" } +func (quickTestProfile) BinaryName() string { return "test-agent" } +func (quickTestProfile) SupportedBackends() []profiles.Backend { + return []profiles.Backend{ + {Type: profiles.BackendAnthropic, DisplayName: "Anthropic"}, + } +} +func (quickTestProfile) Env(string, profiles.Backend) (map[string]string, error) { + return map[string]string{}, nil +} +func (quickTestProfile) RequiredCompat(profiles.Backend) []string { + return []string{"anthropic_messages"} +} +func (quickTestProfile) ApplyModel(model string, env map[string]string) { + env["MODEL"] = model +} + +func TestRefreshLastSelectionResolvesProviderAndModel(t *testing.T) { + p := quickTestProfile{} + m := model{ + apertureHost: "http://ai", + state: profiles.StateFile{LastProfileName: p.Name(), LastBackendType: string(profiles.BackendAnthropic), LastProviderID: "provider-one", LastModel: "provider-one/model-b"}, + manager: profiles.NewManager(), + installedProfiles: []profiles.Profile{p}, + providers: []profiles.ProviderInfo{ + { + ID: "provider-one", + Name: "Provider One", + Models: []string{"model-a", "model-b"}, + Compatibility: map[string]bool{"anthropic_messages": true}, + }, + }, + step: stepSelectProfile, + } + + m.refreshLastSelection() + + if m.lastSelection == nil { + t.Fatal("lastSelection is nil") + } + if got := m.lastSelection.provider.ID; got != "provider-one" { + t.Fatalf("provider ID = %q, want %q", got, "provider-one") + } + if got := m.lastSelection.selectedModel; got != "provider-one/model-b" { + t.Fatalf("selected model = %q, want %q", got, "provider-one/model-b") + } + + view := m.View() + if !strings.Contains(view, "[0] Quick select: Test Agent via Provider One - Anthropic - provider-one/model-b") { + t.Fatalf("View() missing quick select row:\n%s", view) + } + if strings.Index(view, "[0] Quick select") > strings.Index(view, "[1] Test Agent") { + t.Fatalf("quick select row should appear before profile options:\n%s", view) + } + + m.resetProfileCursor() + if m.profileCursor != -1 { + t.Fatalf("profileCursor = %d, want -1 for quick select", m.profileCursor) + } + + updated, _ := m.updateSelectProfile(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(model) + if m.profileCursor != 0 { + t.Fatalf("profileCursor after down = %d, want 0", m.profileCursor) + } + + updated, _ = m.updateSelectProfile(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(model) + if m.profileCursor != -1 { + t.Fatalf("profileCursor after up = %d, want -1", m.profileCursor) + } +} + +func TestRefreshLastSelectionRejectsMissingModel(t *testing.T) { + p := quickTestProfile{} + m := model{ + state: profiles.StateFile{LastProfileName: p.Name(), LastBackendType: string(profiles.BackendAnthropic), LastProviderID: "provider-one", LastModel: "provider-one/missing"}, + manager: profiles.NewManager(), + installedProfiles: []profiles.Profile{p}, + providers: []profiles.ProviderInfo{ + { + ID: "provider-one", + Name: "Provider One", + Models: []string{"model-a", "model-b"}, + Compatibility: map[string]bool{"anthropic_messages": true}, + }, + }, + } + + m.refreshLastSelection() + + if m.lastSelection != nil { + t.Fatalf("lastSelection = %#v, want nil", m.lastSelection) + } +} From 13eba92993c8f1fde00489347f05403b4438dd7a Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Wed, 29 Apr 2026 23:46:08 +0000 Subject: [PATCH 04/15] Update Codex yolo flag --- internal/profiles/codex.go | 2 +- internal/profiles/profiles_test.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/profiles/codex.go b/internal/profiles/codex.go index e3272e1..507dd78 100644 --- a/internal/profiles/codex.go +++ b/internal/profiles/codex.go @@ -47,7 +47,7 @@ func (c *CodexProfile) Uninstall() func() error { } func (c *CodexProfile) YoloArgs() []string { - return []string{"--full-auto"} + return []string{"--dangerously-bypass-approvals-and-sandbox"} } func (c *CodexProfile) RequiredCompat(b Backend) []string { diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go index ae73375..f656499 100644 --- a/internal/profiles/profiles_test.go +++ b/internal/profiles/profiles_test.go @@ -339,8 +339,8 @@ func TestLauncher_Codex_Env_UnsupportedBackend(t *testing.T) { func TestLauncher_Codex_YoloArgs(t *testing.T) { p := &profiles.CodexProfile{} args := p.YoloArgs() - if len(args) != 1 || args[0] != "--full-auto" { - t.Errorf("YoloArgs() = %v, want [--full-auto]", args) + if len(args) != 1 || args[0] != "--dangerously-bypass-approvals-and-sandbox" { + t.Errorf("YoloArgs() = %v, want [--dangerously-bypass-approvals-and-sandbox]", args) } } @@ -900,8 +900,8 @@ func TestLauncher_BackendsForProvider_NoCompatChecker(t *testing.T) { mgr := profiles.NewManager() p := &noCompatProfile{} provider := profiles.ProviderInfo{ - ID: "any", - Name: "Any", + ID: "any", + Name: "Any", Compatibility: map[string]bool{}, } @@ -1003,8 +1003,8 @@ func TestLauncher_StateFile_LastProviderID_RoundTrip(t *testing.T) { // noCompatProfile is a test Profile that does not implement CompatChecker. type noCompatProfile struct{} -func (noCompatProfile) Name() string { return "no-compat" } -func (noCompatProfile) BinaryName() string { return "no-compat-binary" } +func (noCompatProfile) Name() string { return "no-compat" } +func (noCompatProfile) BinaryName() string { return "no-compat-binary" } func (noCompatProfile) SupportedBackends() []profiles.Backend { return []profiles.Backend{ {Type: profiles.BackendAnthropic, DisplayName: "Anthropic"}, From 2c157788063eb4efba8a8a47f8ecdce7d60d3e27 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Wed, 29 Apr 2026 23:54:09 +0000 Subject: [PATCH 05/15] Pass Codex model selection as launch args --- internal/profiles/codex.go | 7 +++++++ internal/profiles/profiles.go | 6 ++++++ internal/profiles/profiles_test.go | 10 ++++++++++ internal/tui/tui.go | 7 ++++++- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/profiles/codex.go b/internal/profiles/codex.go index 507dd78..553cbf4 100644 --- a/internal/profiles/codex.go +++ b/internal/profiles/codex.go @@ -63,6 +63,13 @@ func (c *CodexProfile) ApplyModel(model string, env map[string]string) { env["OPENAI_MODEL"] = model } +func (c *CodexProfile) ModelArgs(model string) []string { + if model == "" { + return nil + } + return []string{"--model", model} +} + func (c *CodexProfile) Env(apertureHost string, b Backend) (map[string]string, error) { switch b.Type { case BackendOpenAI: diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index a3d089a..526db96 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -84,6 +84,12 @@ type ModelSelector interface { ApplyModel(model string, env map[string]string) } +// ModelArgSelector is implemented by profiles that need a user-chosen +// default model passed as command-line arguments. +type ModelArgSelector interface { + ModelArgs(model string) []string +} + // Combo is a resolved (profile, backend) pair. type Combo struct { Profile Profile diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go index f656499..2ae8563 100644 --- a/internal/profiles/profiles_test.go +++ b/internal/profiles/profiles_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" @@ -344,6 +345,15 @@ func TestLauncher_Codex_YoloArgs(t *testing.T) { } } +func TestLauncher_Codex_ModelArgs(t *testing.T) { + p := &profiles.CodexProfile{} + args := p.ModelArgs("test-provider/gpt-5.3-codex") + want := []string{"--model", "test-provider/gpt-5.3-codex"} + if !reflect.DeepEqual(args, want) { + t.Errorf("ModelArgs() = %v, want %v", args, want) + } +} + func TestLauncher_Codex_WriteConfig(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ce17290..c6b2627 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1196,9 +1196,14 @@ func (m model) execCombo(combo profiles.Combo) tea.Cmd { } var extraArgs []string + if m.selectedModel != "" { + if ma, ok := combo.Profile.(profiles.ModelArgSelector); ok { + extraArgs = append(extraArgs, ma.ModelArgs(m.selectedModel)...) + } + } if m.settings.YoloMode { if yp, ok := combo.Profile.(profiles.YoloProfile); ok { - extraArgs = yp.YoloArgs() + extraArgs = append(extraArgs, yp.YoloArgs()...) } } From 71ed73dbbfb68eaf7f73f1e663958dbd69500247 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Thu, 30 Apr 2026 00:08:29 +0000 Subject: [PATCH 06/15] Show build version in main menu --- Makefile | 3 ++- cmd/aperture/main.go | 51 ++++++++++++++++++++++++++++++++++++---- internal/tui/tui.go | 14 ++++++++--- internal/tui/tui_test.go | 16 +++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index b874bcc..6088d85 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,14 @@ .PHONY: build test clean install BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +GIT_HEIGHT := $(shell git rev-list --count HEAD 2>/dev/null || echo 0) GIT_DESC := $(shell git describe --always) ifneq ($(shell git status --porcelain),) GIT_DESC := $(GIT_DESC)-dirty endif -LDFLAGS := -X main.buildCommit=$(GIT_DESC) -X main.buildDate=$(BUILD_DATE) +LDFLAGS := -X main.buildVersion=B$(GIT_HEIGHT) -X main.buildCommit=$(GIT_DESC) -X main.buildDate=$(BUILD_DATE) build: go build -ldflags "$(LDFLAGS)" -o .build/aperture ./cmd/aperture diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index 27efe62..83588aa 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -5,7 +5,11 @@ import ( "fmt" "log/slog" "os" + "os/exec" + "path/filepath" + "runtime" "runtime/debug" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/tailscale/aperture-cli/internal/profiles" @@ -16,7 +20,7 @@ var ( flagVersion = flag.Bool("version", false, "print version and exit") flagDebug = flag.Bool("debug", false, "print env vars set before launching agent") - buildVersion = "v0.0.0-dev" + buildVersion = "B0-dev" buildCommit = "unknown" buildDate = "unknown" ) @@ -27,8 +31,12 @@ func init() { return } - if buildVersion == "v0.0.0-dev" && info.Main.Version != "" && info.Main.Version != "(devel)" { - buildVersion = info.Main.Version + if buildVersion == "B0-dev" { + if height := gitCommitHeight(); height != "" { + buildVersion = "B" + height + } else if info.Main.Version != "" && info.Main.Version != "(devel)" { + buildVersion = info.Main.Version + } } // Only fill in VCS info when ldflags haven't already set these values. @@ -56,6 +64,41 @@ func init() { } } +func gitCommitHeight() string { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "" + } + for dir := filepath.Dir(file); ; dir = filepath.Dir(dir) { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return gitCommitHeightInDir(dir) + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + } +} + +func gitCommitHeightInDir(dir string) string { + cmd := exec.Command("git", "rev-list", "--count", "HEAD") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + height := strings.TrimSpace(string(out)) + if height == "" { + return "" + } + for _, r := range height { + if r < '0' || r > '9' { + return "" + } + } + return height +} + func main() { flag.Parse() @@ -77,7 +120,7 @@ func main() { host = settings.Endpoints[0].URL } - p := tea.NewProgram(tui.NewModel(host, settings, state, *flagDebug)) + p := tea.NewProgram(tui.NewModel(host, settings, state, buildVersion, *flagDebug)) if _, err := p.Run(); err != nil { slog.Error("launcher error", "err", err) os.Exit(1) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c6b2627..04f8a58 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -114,12 +114,13 @@ type model struct { // add-location step addLocationInput string - debug bool - err string + buildVersion string + debug bool + err string } // NewModel constructs the TUI model. It satisfies tea.Model. -func NewModel(apertureHost string, settings profiles.Settings, state profiles.StateFile, debug bool) tea.Model { +func NewModel(apertureHost string, settings profiles.Settings, state profiles.StateFile, buildVersion string, debug bool) tea.Model { mgr := profiles.NewManager() m := model{ @@ -129,6 +130,7 @@ func NewModel(apertureHost string, settings profiles.Settings, state profiles.St manager: mgr, allProfiles: mgr.AllProfiles(), installedProfiles: mgr.InstalledProfiles(), + buildVersion: buildVersion, debug: debug, step: stepPreflight, preflightChecking: true, @@ -1294,6 +1296,12 @@ func (m model) View() string { sb.WriteString("\n") sb.WriteString(dimStyle.Render("Selection: ")) + if m.buildVersion != "" { + sb.WriteString("\n\n") + sb.WriteString(dimStyle.Render("Aperture " + m.buildVersion)) + sb.WriteString("\n") + } + case stepSelectProvider: sb.WriteString(titleStyle.Render(fmt.Sprintf("Choose a provider for %s:", m.chosenProfile.Name()))) sb.WriteString("\n") diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index c3dadba..7b18f14 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -105,3 +105,19 @@ func TestRefreshLastSelectionRejectsMissingModel(t *testing.T) { t.Fatalf("lastSelection = %#v, want nil", m.lastSelection) } } + +func TestSelectProfileViewShowsBuildVersion(t *testing.T) { + m := model{ + apertureHost: "http://ai", + buildVersion: "B123", + manager: profiles.NewManager(), + providers: []profiles.ProviderInfo{{ID: "provider-one"}}, + profileCursor: 0, + step: stepSelectProfile, + } + + view := m.View() + if !strings.Contains(view, "Aperture B123") { + t.Fatalf("View() missing build version:\n%s", view) + } +} From 0da535d92ce46a0f832423698797226aad9a7591 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Thu, 30 Apr 2026 21:37:02 +0000 Subject: [PATCH 07/15] profiles,tui: drive OpenCode config from provider compatibility map OpenCode's UX previously asked the user to pick a backend after picking a provider, and the generated temp config was hardcoded per backend type rather than reflecting the chosen aperture provider. Collapse OpenCode's SupportedBackends to one abstract entry so the backend step is skipped, and add a ProviderConfigWriter interface that lets profiles generate config tailored to the chosen ProviderInfo. OpenCode's WriteProviderConfig now picks the AI SDK npm package and options from the provider's compatibility map (anthropic_messages, bedrock_converse, google_*, openai_responses, openai_chat) and writes the provider's id, name, and full model list into the temp config. For Vertex providers, set apiKey to trigger the SDK's express mode so it skips google-auth-library's ADC lookup, and embed the full project-scoped path (with the _aperture_auto_*_ placeholders aperture rewrites server-side) in baseURL so the request URL matches aperture's vertex router pattern. --- internal/profiles/opencode.go | 150 ++++++++++---------- internal/profiles/profiles.go | 8 ++ internal/profiles/profiles_test.go | 215 ++++++++++++++++------------- internal/tui/tui.go | 11 +- 4 files changed, 216 insertions(+), 168 deletions(-) diff --git a/internal/profiles/opencode.go b/internal/profiles/opencode.go index 959572f..d7c26b2 100644 --- a/internal/profiles/opencode.go +++ b/internal/profiles/opencode.go @@ -2,7 +2,6 @@ package profiles import ( "encoding/json" - "fmt" "os" "os/exec" "path/filepath" @@ -48,58 +47,47 @@ func (o *OpenCodeProfile) Uninstall() func() error { } } +// openCodeBackend is the single abstract backend OpenCode advertises. The +// real routing is decided per-provider from its compatibility map. +var openCodeBackend = Backend{Type: BackendOpenAI, DisplayName: "OpenCode"} + func (o *OpenCodeProfile) SupportedBackends() []Backend { - return []Backend{ - {Type: BackendAnthropic, DisplayName: "Anthropic API"}, - {Type: BackendBedrock, DisplayName: "AWS Bedrock"}, - {Type: BackendVertex, DisplayName: "Google Vertex"}, - {Type: BackendOpenAI, DisplayName: "OpenAI Compatible"}, - } + return []Backend{openCodeBackend} } -func (o *OpenCodeProfile) RequiredCompat(b Backend) []string { - switch b.Type { - case BackendAnthropic: - return []string{"anthropic_messages"} - case BackendBedrock: - return []string{"bedrock_converse"} - case BackendVertex: - return []string{"google_generate_content"} - case BackendOpenAI: - return []string{"openai_chat"} - default: - return nil +// RequiredCompat accepts any provider that speaks one of the protocols we can +// translate into an OpenCode config. +func (o *OpenCodeProfile) RequiredCompat(Backend) []string { + return []string{ + "anthropic_messages", + "bedrock_converse", + "google_generate_content", + "google_raw_predict", + "openai_responses", + "openai_chat", } } -func (o *OpenCodeProfile) Env(apertureHost string, b Backend) (map[string]string, error) { - switch b.Type { - case BackendAnthropic: - return map[string]string{ - "ANTHROPIC_BASE_URL": apertureHost + "/v1", - "ANTHROPIC_AUTH_TOKEN": "-", - }, nil - case BackendBedrock: - // Dummy AWS credentials so the SDK doesn't fail credential resolution; - // aperture handles actual auth at the /bedrock endpoint. +// Env returns backend-agnostic env vars. Provider-specific env vars (AWS, +// Google Vertex magic strings) are set via ProviderEnv. +func (o *OpenCodeProfile) Env(string, Backend) (map[string]string, error) { + return map[string]string{}, nil +} + +// ProviderEnv sets env vars that depend on the chosen provider's protocol. +func (o *OpenCodeProfile) ProviderEnv(_ Backend, providers []ProviderInfo) map[string]string { + if len(providers) == 0 { + return nil + } + p := providers[0] + if p.Compatibility["bedrock_converse"] { return map[string]string{ "AWS_ACCESS_KEY_ID": "not-needed", "AWS_SECRET_ACCESS_KEY": "not-needed", "AWS_REGION": "us-east-1", - }, nil - case BackendVertex: - return map[string]string{ - "GOOGLE_CLOUD_PROJECT": "_aperture_auto_vertex_project_id_", - "GOOGLE_CLOUD_LOCATION": "_aperture_auto_vertex_region_", - }, nil - case BackendOpenAI: - return map[string]string{ - "OPENAI_API_KEY": "not-needed", - "OPENAI_BASE_URL": apertureHost + "/v1", - }, nil - default: - return nil, fmt.Errorf("unsupported backend %q for OpenCode", b.Type) + } } + return nil } type opencodeConfig struct { @@ -108,49 +96,71 @@ type opencodeConfig struct { } type opencodeProvider struct { - Options map[string]string `json:"options,omitempty"` + NPM string `json:"npm,omitempty"` + Name string `json:"name,omitempty"` + Options map[string]string `json:"options,omitempty"` + Models map[string]opencodeModelEntry `json:"models,omitempty"` } -func (o *OpenCodeProfile) WriteConfig(apertureHost string, b Backend) (string, string, func(), error) { - var providerKey string - var options map[string]string +type opencodeModelEntry struct { + Name string `json:"name,omitempty"` +} - switch b.Type { - case BackendAnthropic: - providerKey = "anthropic" - options = map[string]string{ - "apiKey": "{env:ANTHROPIC_AUTH_TOKEN}", - "baseURL": "{env:ANTHROPIC_BASE_URL}", +// pickOpenCodeSDK chooses the AI SDK npm package and baseline options for a +// provider based on its compatibility map. Priority is ordered so that +// protocols with richer native support win over OpenAI-compatible fallback. +func pickOpenCodeSDK(compat map[string]bool, apertureHost string) (npm string, options map[string]string) { + switch { + case compat["anthropic_messages"]: + return "@ai-sdk/anthropic", map[string]string{ + "baseURL": apertureHost + "/v1", + "apiKey": "not-required", } - case BackendBedrock: - providerKey = "amazon-bedrock" - options = map[string]string{ + case compat["bedrock_converse"]: + return "@ai-sdk/amazon-bedrock", map[string]string{ "region": "us-east-1", "endpoint": apertureHost + "/bedrock", } - case BackendVertex: - providerKey = "google-vertex" - options = map[string]string{ - "project": "_aperture_auto_vertex_project_id_", - "location": "_aperture_auto_vertex_region_", - "baseURL": apertureHost + "/v1", + case compat["google_generate_content"] || compat["google_raw_predict"]: + // Setting apiKey triggers the Vertex SDK's "express mode" which skips + // google-auth-library / ADC. We still need the full project-scoped + // path because aperture's vertex router only matches that pattern; + // the magic _aperture_auto_*_ placeholders are rewritten upstream. + return "@ai-sdk/google-vertex", map[string]string{ + "apiKey": "not-required", + "baseURL": apertureHost + "/v1/projects/_aperture_auto_vertex_project_id_/locations/_aperture_auto_vertex_region_/publishers/google", } - case BackendOpenAI: - providerKey = "openai" - options = map[string]string{ - "apiKey": "{env:OPENAI_API_KEY}", - "baseURL": "{env:OPENAI_BASE_URL}", + case compat["openai_responses"]: + return "@ai-sdk/openai", map[string]string{ + "baseURL": apertureHost + "/v1", + "apiKey": "not-required", + } + case compat["openai_chat"]: + return "@ai-sdk/openai-compatible", map[string]string{ + "baseURL": apertureHost + "/v1", + "apiKey": "not-required", } - default: - return "", "", nil, fmt.Errorf("unsupported backend %q for OpenCode", b.Type) } + return "", nil +} - provider := opencodeProvider{Options: options} +func (o *OpenCodeProfile) WriteProviderConfig(apertureHost string, _ Backend, p ProviderInfo) (string, string, func(), error) { + npm, options := pickOpenCodeSDK(p.Compatibility, apertureHost) + + models := make(map[string]opencodeModelEntry, len(p.Models)) + for _, m := range p.Models { + models[m] = opencodeModelEntry{Name: m} + } cfg := opencodeConfig{ Schema: "https://opencode.ai/config.json", Provider: map[string]opencodeProvider{ - providerKey: provider, + p.ID: { + NPM: npm, + Name: "Aperture (" + p.ID + ")", + Options: options, + Models: models, + }, }, } diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index 526db96..3840714 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -42,6 +42,14 @@ type ConfigWriter interface { WriteConfig(apertureHost string, b Backend) (envKey, configPath string, cleanup func(), err error) } +// ProviderConfigWriter is implemented by profiles that generate a config file +// tailored to a specific provider (its ID, name, model list, and the HTTP +// protocol implied by its compatibility map). When a profile implements this +// interface, the TUI prefers it over ConfigWriter. +type ProviderConfigWriter interface { + WriteProviderConfig(apertureHost string, b Backend, p ProviderInfo) (envKey, configPath string, cleanup func(), err error) +} + // YoloProfile is implemented by profiles that support a "skip permissions" // flag. The returned args are appended to the command when YOLO mode is on. type YoloProfile interface { diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go index 2ae8563..ab9c9b4 100644 --- a/internal/profiles/profiles_test.go +++ b/internal/profiles/profiles_test.go @@ -157,152 +157,183 @@ func TestLauncher_StateFile_RoundTrip(t *testing.T) { } } -func TestLauncher_OpenCode_Env_Anthropic(t *testing.T) { +func TestLauncher_OpenCode_SupportedBackends_Single(t *testing.T) { p := &profiles.OpenCodeProfile{} - env, err := p.Env(testHost, profiles.Backend{Type: profiles.BackendAnthropic}) - if err != nil { - t.Fatalf("Env returned error: %v", err) - } - if got := env["ANTHROPIC_BASE_URL"]; got != testHost+"/v1" { - t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", got, testHost+"/v1") + if got := p.SupportedBackends(); len(got) != 1 { + t.Errorf("SupportedBackends len = %d, want 1", len(got)) } } -func TestLauncher_OpenCode_Env_Bedrock(t *testing.T) { +func TestLauncher_OpenCode_ProviderEnv(t *testing.T) { p := &profiles.OpenCodeProfile{} - env, err := p.Env(testHost, profiles.Backend{Type: profiles.BackendBedrock}) - if err != nil { - t.Fatalf("Env returned error: %v", err) - } - if got := env["AWS_ACCESS_KEY_ID"]; got != "not-needed" { - t.Errorf("AWS_ACCESS_KEY_ID = %q, want %q", got, "not-needed") + b := profiles.Backend{Type: profiles.BackendOpenAI} + + bedrock := profiles.ProviderInfo{ + ID: "bedrock", Compatibility: map[string]bool{"bedrock_converse": true}, } - if got := env["AWS_REGION"]; got != "us-east-1" { - t.Errorf("AWS_REGION = %q, want %q", got, "us-east-1") + env := p.ProviderEnv(b, []profiles.ProviderInfo{bedrock}) + if env["AWS_ACCESS_KEY_ID"] != "not-needed" || env["AWS_REGION"] != "us-east-1" { + t.Errorf("bedrock ProviderEnv = %v", env) } -} -func TestLauncher_OpenCode_Env_Vertex(t *testing.T) { - p := &profiles.OpenCodeProfile{} - env, err := p.Env(testHost, profiles.Backend{Type: profiles.BackendVertex}) - if err != nil { - t.Fatalf("Env returned error: %v", err) - } - if got := env["GOOGLE_CLOUD_PROJECT"]; got != "_aperture_auto_vertex_project_id_" { - t.Errorf("GOOGLE_CLOUD_PROJECT = %q, want %q", got, "_aperture_auto_vertex_project_id_") + vertex := profiles.ProviderInfo{ + ID: "vertex", Compatibility: map[string]bool{"google_generate_content": true}, } - if got := env["GOOGLE_CLOUD_LOCATION"]; got != "_aperture_auto_vertex_region_" { - t.Errorf("GOOGLE_CLOUD_LOCATION = %q, want %q", got, "_aperture_auto_vertex_region_") + if env := p.ProviderEnv(b, []profiles.ProviderInfo{vertex}); len(env) != 0 { + t.Errorf("vertex ProviderEnv = %v, want empty (express mode)", env) } -} -func TestLauncher_OpenCode_Env_OpenAI(t *testing.T) { - p := &profiles.OpenCodeProfile{} - env, err := p.Env(testHost, profiles.Backend{Type: profiles.BackendOpenAI}) - if err != nil { - t.Fatalf("Env returned error: %v", err) + anthropic := profiles.ProviderInfo{ + ID: "anthropic", Compatibility: map[string]bool{"anthropic_messages": true}, } - if got := env["OPENAI_BASE_URL"]; got != testHost+"/v1" { - t.Errorf("OPENAI_BASE_URL = %q, want %q", got, testHost+"/v1") + if env := p.ProviderEnv(b, []profiles.ProviderInfo{anthropic}); len(env) != 0 { + t.Errorf("anthropic ProviderEnv = %v, want empty", env) } } -func TestLauncher_OpenCode_WriteConfig(t *testing.T) { +func TestLauncher_OpenCode_WriteProviderConfig(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) p := &profiles.OpenCodeProfile{} - cw, ok := profiles.Profile(p).(profiles.ConfigWriter) + cw, ok := profiles.Profile(p).(profiles.ProviderConfigWriter) if !ok { - t.Fatal("OpenCodeProfile does not implement ConfigWriter") + t.Fatal("OpenCodeProfile does not implement ProviderConfigWriter") } tests := []struct { name string - backend profiles.Backend - wantKey string + provider profiles.ProviderInfo + wantNPM string wantOptions map[string]string }{ { - name: "anthropic", - backend: profiles.Backend{Type: profiles.BackendAnthropic}, - wantKey: "anthropic", + name: "anthropic_messages", + provider: profiles.ProviderInfo{ + ID: "anthropic", Name: "Anthropic", + Models: []string{"claude-sonnet-4-5", "claude-haiku-4-5"}, + Compatibility: map[string]bool{"anthropic_messages": true}, + }, + wantNPM: "@ai-sdk/anthropic", wantOptions: map[string]string{ - "apiKey": "{env:ANTHROPIC_AUTH_TOKEN}", - "baseURL": "{env:ANTHROPIC_BASE_URL}", + "baseURL": testHost + "/v1", + "apiKey": "not-required", }, }, { - name: "bedrock", - backend: profiles.Backend{Type: profiles.BackendBedrock}, - wantKey: "amazon-bedrock", + name: "bedrock_converse", + provider: profiles.ProviderInfo{ + ID: "bedrock", Name: "AWS Bedrock", + Models: []string{"us.anthropic.claude-opus-4-7"}, + Compatibility: map[string]bool{"bedrock_converse": true}, + }, + wantNPM: "@ai-sdk/amazon-bedrock", wantOptions: map[string]string{ "region": "us-east-1", "endpoint": testHost + "/bedrock", }, }, { - name: "vertex", - backend: profiles.Backend{Type: profiles.BackendVertex}, - wantKey: "google-vertex", + name: "google_generate_content", + provider: profiles.ProviderInfo{ + ID: "vertex", Name: "Vertex", + Models: []string{"gemini-2.5-pro"}, + Compatibility: map[string]bool{ + "google_generate_content": true, + "google_raw_predict": true, + }, + }, + wantNPM: "@ai-sdk/google-vertex", + wantOptions: map[string]string{ + "apiKey": "not-required", + "baseURL": testHost + "/v1/projects/_aperture_auto_vertex_project_id_/locations/_aperture_auto_vertex_region_/publishers/google", + }, + }, + { + name: "openai_responses", + provider: profiles.ProviderInfo{ + ID: "openai", Name: "OpenAI", + Models: []string{"gpt-5"}, + Compatibility: map[string]bool{ + "openai_chat": true, + "openai_responses": true, + }, + }, + wantNPM: "@ai-sdk/openai", wantOptions: map[string]string{ - "project": "_aperture_auto_vertex_project_id_", - "location": "_aperture_auto_vertex_region_", - "baseURL": testHost + "/v1", + "baseURL": testHost + "/v1", + "apiKey": "not-required", }, }, { - name: "openai", - backend: profiles.Backend{Type: profiles.BackendOpenAI}, - wantKey: "openai", + name: "openai_chat_only", + provider: profiles.ProviderInfo{ + ID: "openrouter", Name: "OpenRouter", + Models: []string{"qwen/qwen3-235b-a22b-2507"}, + Compatibility: map[string]bool{"openai_chat": true}, + }, + wantNPM: "@ai-sdk/openai-compatible", wantOptions: map[string]string{ - "apiKey": "{env:OPENAI_API_KEY}", - "baseURL": "{env:OPENAI_BASE_URL}", + "baseURL": testHost + "/v1", + "apiKey": "not-required", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, configPath, cleanup, err := cw.WriteConfig(testHost, tt.backend) + envKey, configPath, cleanup, err := cw.WriteProviderConfig(testHost, profiles.Backend{Type: profiles.BackendOpenAI}, tt.provider) if err != nil { - t.Fatalf("WriteConfig returned error: %v", err) + t.Fatalf("WriteProviderConfig returned error: %v", err) + } + if envKey != "OPENCODE_CONFIG" { + t.Errorf("envKey = %q, want OPENCODE_CONFIG", envKey) } - // File must exist data, err := os.ReadFile(configPath) if err != nil { t.Fatalf("config file not readable: %v", err) } - // Must be valid JSON with expected structure - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { + var cfg struct { + Provider map[string]struct { + NPM string `json:"npm"` + Name string `json:"name"` + Options map[string]string `json:"options"` + Models map[string]map[string]string `json:"models"` + } `json:"provider"` + } + if err := json.Unmarshal(data, &cfg); err != nil { t.Fatalf("config file is not valid JSON: %v", err) } - providerRaw, ok := raw["provider"] + + prov, ok := cfg.Provider[tt.provider.ID] if !ok { - t.Fatal("config missing 'provider' key") + t.Fatalf("provider %q not found in config", tt.provider.ID) } - var providers map[string]struct { - Options map[string]string `json:"options"` + if prov.NPM != tt.wantNPM { + t.Errorf("npm = %q, want %q", prov.NPM, tt.wantNPM) } - if err := json.Unmarshal(providerRaw, &providers); err != nil { - t.Fatalf("provider not valid JSON: %v", err) - } - prov, ok := providers[tt.wantKey] - if !ok { - t.Fatalf("provider %q not found in config", tt.wantKey) + wantName := "Aperture (" + tt.provider.ID + ")" + if prov.Name != wantName { + t.Errorf("name = %q, want %q", prov.Name, wantName) } for k, want := range tt.wantOptions { if got := prov.Options[k]; got != want { t.Errorf("options[%q] = %q, want %q", k, got, want) } } + if len(prov.Models) != len(tt.provider.Models) { + t.Errorf("models len = %d, want %d", len(prov.Models), len(tt.provider.Models)) + } + for _, m := range tt.provider.Models { + if _, ok := prov.Models[m]; !ok { + t.Errorf("model %q missing from config", m) + } + } - // cleanup removes the file cleanup() if _, err := os.Stat(configPath); !os.IsNotExist(err) { t.Errorf("config file still exists after cleanup") @@ -477,33 +508,23 @@ func TestLauncher_FilteredBackends_NilProviders(t *testing.T) { } } -func TestLauncher_RequiredCompat_OpenCodeBedrock(t *testing.T) { +func TestLauncher_RequiredCompat_OpenCode(t *testing.T) { p := &profiles.OpenCodeProfile{} - keys := p.RequiredCompat(profiles.Backend{Type: profiles.BackendBedrock}) + keys := p.RequiredCompat(profiles.Backend{}) if len(keys) == 0 { - t.Fatal("expected at least one compat key for OpenCode+Bedrock") + t.Fatal("expected at least one compat key for OpenCode") } - // Verify that a provider with bedrock_converse satisfies the requirement. + // Verify that providers with any of the supported protocols appear as + // compatible for OpenCode. mgr := profiles.NewManager() - providers := []profiles.ProviderInfo{ - { - ID: "bedrock-provider", - Name: "Bedrock", - Compatibility: map[string]bool{ - "bedrock_converse": true, - }, - }, - } - backends := mgr.FilteredBackends(p, providers) - found := false - for _, b := range backends { - if b.Type == profiles.BackendBedrock { - found = true + for _, compat := range []string{"anthropic_messages", "bedrock_converse", "google_generate_content", "openai_chat"} { + providers := []profiles.ProviderInfo{ + {ID: "p", Compatibility: map[string]bool{compat: true}}, + } + if got := mgr.CompatibleProviders(p, providers); len(got) != 1 { + t.Errorf("provider with %q not compatible with OpenCode", compat) } - } - if !found { - t.Error("expected Bedrock backend to be available with bedrock_converse provider") } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 04f8a58..1757428 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1176,7 +1176,16 @@ func (m model) execCombo(combo profiles.Combo) tea.Cmd { var configCleanup func() var configEnvKey, configPath string - if cw, ok := combo.Profile.(profiles.ConfigWriter); ok { + if cw, ok := combo.Profile.(profiles.ProviderConfigWriter); ok { + var err error + configEnvKey, configPath, configCleanup, err = cw.WriteProviderConfig(m.apertureHost, combo.Backend, m.chosenProvider) + if err != nil { + return tea.Quit + } + if configEnvKey != "" && configPath != "" { + envPairs = append(envPairs, configEnvKey+"="+configPath) + } + } else if cw, ok := combo.Profile.(profiles.ConfigWriter); ok { var err error configEnvKey, configPath, configCleanup, err = cw.WriteConfig(m.apertureHost, combo.Backend) if err != nil { From 0e433c0dde3fd3b27913fb55d483780d853bcf6f Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Thu, 30 Apr 2026 23:01:45 +0000 Subject: [PATCH 08/15] internal/profiles: refine OpenCode protocol priority and pin model list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder pickOpenCodeSDK so providers exposing multiple compat keys land on the preferred SDK: openai_responses → anthropic_messages → openai_chat → google_* → bedrock_* → gemini_generate_content. Add handlers for bedrock_model_invoke and gemini_generate_content (mapped to @ai-sdk/google against /v1beta). Pin the model list to what aperture reports by writing a "whitelist" field alongside the models map. Without it, OpenCode merges its built-in models.dev database entries on top of our config when the provider key matches a known ID like "openai" or "anthropic". The whitelist makes provider.ts:1374 strip the database-sourced models so only ours remain. Use fully qualified "/" identifiers as the model map keys and display names, keeping the bare model name in the "id" field so OpenCode's API requests still hit the upstream model. --- internal/profiles/opencode.go | 60 +++++++++++++++++++----------- internal/profiles/profiles_test.go | 31 ++++++++++++--- 2 files changed, 63 insertions(+), 28 deletions(-) diff --git a/internal/profiles/opencode.go b/internal/profiles/opencode.go index d7c26b2..107e480 100644 --- a/internal/profiles/opencode.go +++ b/internal/profiles/opencode.go @@ -59,12 +59,14 @@ func (o *OpenCodeProfile) SupportedBackends() []Backend { // translate into an OpenCode config. func (o *OpenCodeProfile) RequiredCompat(Backend) []string { return []string{ + "openai_responses", "anthropic_messages", - "bedrock_converse", + "openai_chat", "google_generate_content", "google_raw_predict", - "openai_responses", - "openai_chat", + "bedrock_model_invoke", + "bedrock_converse", + "gemini_generate_content", } } @@ -80,7 +82,7 @@ func (o *OpenCodeProfile) ProviderEnv(_ Backend, providers []ProviderInfo) map[s return nil } p := providers[0] - if p.Compatibility["bedrock_converse"] { + if p.Compatibility["bedrock_model_invoke"] || p.Compatibility["bedrock_converse"] { return map[string]string{ "AWS_ACCESS_KEY_ID": "not-needed", "AWS_SECRET_ACCESS_KEY": "not-needed", @@ -100,26 +102,36 @@ type opencodeProvider struct { Name string `json:"name,omitempty"` Options map[string]string `json:"options,omitempty"` Models map[string]opencodeModelEntry `json:"models,omitempty"` + // Whitelist limits the active model list to exactly these IDs. Without + // it, OpenCode merges its built-in models.dev database entries on top of + // our config (e.g. for provider IDs like "openai" or "anthropic"). + Whitelist []string `json:"whitelist,omitempty"` } type opencodeModelEntry struct { + ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` } // pickOpenCodeSDK chooses the AI SDK npm package and baseline options for a -// provider based on its compatibility map. Priority is ordered so that -// protocols with richer native support win over OpenAI-compatible fallback. +// provider based on its compatibility map. Order matters: when a provider +// supports multiple protocols, the first match wins. func pickOpenCodeSDK(compat map[string]bool, apertureHost string) (npm string, options map[string]string) { switch { + case compat["openai_responses"]: + return "@ai-sdk/openai", map[string]string{ + "baseURL": apertureHost + "/v1", + "apiKey": "not-required", + } case compat["anthropic_messages"]: return "@ai-sdk/anthropic", map[string]string{ "baseURL": apertureHost + "/v1", "apiKey": "not-required", } - case compat["bedrock_converse"]: - return "@ai-sdk/amazon-bedrock", map[string]string{ - "region": "us-east-1", - "endpoint": apertureHost + "/bedrock", + case compat["openai_chat"]: + return "@ai-sdk/openai-compatible", map[string]string{ + "baseURL": apertureHost + "/v1", + "apiKey": "not-required", } case compat["google_generate_content"] || compat["google_raw_predict"]: // Setting apiKey triggers the Vertex SDK's "express mode" which skips @@ -130,14 +142,14 @@ func pickOpenCodeSDK(compat map[string]bool, apertureHost string) (npm string, o "apiKey": "not-required", "baseURL": apertureHost + "/v1/projects/_aperture_auto_vertex_project_id_/locations/_aperture_auto_vertex_region_/publishers/google", } - case compat["openai_responses"]: - return "@ai-sdk/openai", map[string]string{ - "baseURL": apertureHost + "/v1", - "apiKey": "not-required", + case compat["bedrock_model_invoke"] || compat["bedrock_converse"]: + return "@ai-sdk/amazon-bedrock", map[string]string{ + "region": "us-east-1", + "endpoint": apertureHost + "/bedrock", } - case compat["openai_chat"]: - return "@ai-sdk/openai-compatible", map[string]string{ - "baseURL": apertureHost + "/v1", + case compat["gemini_generate_content"]: + return "@ai-sdk/google", map[string]string{ + "baseURL": apertureHost + "/v1beta", "apiKey": "not-required", } } @@ -148,18 +160,22 @@ func (o *OpenCodeProfile) WriteProviderConfig(apertureHost string, _ Backend, p npm, options := pickOpenCodeSDK(p.Compatibility, apertureHost) models := make(map[string]opencodeModelEntry, len(p.Models)) + whitelist := make([]string, 0, len(p.Models)) for _, m := range p.Models { - models[m] = opencodeModelEntry{Name: m} + fqn := p.ID + "/" + m + models[fqn] = opencodeModelEntry{ID: m, Name: fqn} + whitelist = append(whitelist, fqn) } cfg := opencodeConfig{ Schema: "https://opencode.ai/config.json", Provider: map[string]opencodeProvider{ p.ID: { - NPM: npm, - Name: "Aperture (" + p.ID + ")", - Options: options, - Models: models, + NPM: npm, + Name: "Aperture (" + p.ID + ")", + Options: options, + Models: models, + Whitelist: whitelist, }, }, } diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go index ab9c9b4..1100bab 100644 --- a/internal/profiles/profiles_test.go +++ b/internal/profiles/profiles_test.go @@ -299,10 +299,11 @@ func TestLauncher_OpenCode_WriteProviderConfig(t *testing.T) { var cfg struct { Provider map[string]struct { - NPM string `json:"npm"` - Name string `json:"name"` - Options map[string]string `json:"options"` - Models map[string]map[string]string `json:"models"` + NPM string `json:"npm"` + Name string `json:"name"` + Options map[string]string `json:"options"` + Models map[string]map[string]string `json:"models"` + Whitelist []string `json:"whitelist"` } `json:"provider"` } if err := json.Unmarshal(data, &cfg); err != nil { @@ -329,8 +330,26 @@ func TestLauncher_OpenCode_WriteProviderConfig(t *testing.T) { t.Errorf("models len = %d, want %d", len(prov.Models), len(tt.provider.Models)) } for _, m := range tt.provider.Models { - if _, ok := prov.Models[m]; !ok { - t.Errorf("model %q missing from config", m) + fqn := tt.provider.ID + "/" + m + entry, ok := prov.Models[fqn] + if !ok { + t.Errorf("model %q missing from config", fqn) + continue + } + if entry["id"] != m { + t.Errorf("model %q id = %q, want %q", fqn, entry["id"], m) + } + if entry["name"] != fqn { + t.Errorf("model %q name = %q, want %q", fqn, entry["name"], fqn) + } + } + if len(prov.Whitelist) != len(tt.provider.Models) { + t.Errorf("whitelist len = %d, want %d", len(prov.Whitelist), len(tt.provider.Models)) + } + for i, m := range tt.provider.Models { + fqn := tt.provider.ID + "/" + m + if i < len(prov.Whitelist) && prov.Whitelist[i] != fqn { + t.Errorf("whitelist[%d] = %q, want %q", i, prov.Whitelist[i], fqn) } } From 7ec0cab88fd88175320dc2a2108390be9d057fa5 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Thu, 30 Apr 2026 23:29:25 +0000 Subject: [PATCH 09/15] internal/profiles: strip provider prefix from ANTHROPIC_MODEL Claude Code on Bedrock embeds ANTHROPIC_MODEL in the request URL path, so passing the FQN "providerID/modelID" produced /bedrock/model/bedrock//invoke-with-response-stream and Aperture rejected the resulting action. --- internal/profiles/claude_code.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/profiles/claude_code.go b/internal/profiles/claude_code.go index 1e21c57..8c5abd6 100644 --- a/internal/profiles/claude_code.go +++ b/internal/profiles/claude_code.go @@ -117,6 +117,12 @@ func (c *ClaudeCodeProfile) ProviderEnv(b Backend, providers []ProviderInfo) map } func (c *ClaudeCodeProfile) ApplyModel(model string, env map[string]string) { + // model arrives as the FQN "providerID/modelID". For Bedrock the model is + // embedded in the request URL path, so a stray prefix breaks routing + // (e.g. /bedrock/model/bedrock//invoke-with-response-stream). + if i := strings.Index(model, "/"); i >= 0 { + model = model[i+1:] + } env["ANTHROPIC_MODEL"] = model } From 26e36a1d895ef7ab25f9e88d69edef5e2cc48430 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Fri, 1 May 2026 00:22:59 +0000 Subject: [PATCH 10/15] profiles,tui: skip model picker for Claude Code on Bedrock Claude Code on Bedrock resolves models from ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL set in ProviderEnv and the user can switch with /model at runtime, so the launch-time picker adds no value. Add an optional BackendModelSelector interface so a profile can opt out of the picker per backend, and route the TUI's existing ModelSelector checks through a single helper that consults it. --- internal/profiles/claude_code.go | 7 +++++++ internal/profiles/profiles.go | 8 ++++++++ internal/tui/tui.go | 29 +++++++++++++++++++++-------- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/internal/profiles/claude_code.go b/internal/profiles/claude_code.go index 8c5abd6..cdd24e3 100644 --- a/internal/profiles/claude_code.go +++ b/internal/profiles/claude_code.go @@ -126,6 +126,13 @@ func (c *ClaudeCodeProfile) ApplyModel(model string, env map[string]string) { env["ANTHROPIC_MODEL"] = model } +// WantsModelSelection skips the model picker for Bedrock, where Claude Code +// resolves models from ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL set by +// ProviderEnv and the user can switch with /model at runtime. +func (c *ClaudeCodeProfile) WantsModelSelection(b Backend) bool { + return b.Type != BackendBedrock +} + // managedEnvVars returns every environment variable name that the launcher // may set when launching Claude Code, across all backends. var managedEnvVars = []string{ diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index 3840714..e914c2c 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -92,6 +92,14 @@ type ModelSelector interface { ApplyModel(model string, env map[string]string) } +// BackendModelSelector lets a ModelSelector profile opt out of the model +// picker for specific backends (e.g. when the backend determines the model +// from per-tier env vars set in ProviderEnv). When unimplemented, the TUI +// shows the picker for any backend if the profile implements ModelSelector. +type BackendModelSelector interface { + WantsModelSelection(b Backend) bool +} + // ModelArgSelector is implemented by profiles that need a user-chosen // default model passed as command-line arguments. type ModelArgSelector interface { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1757428..618053b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -333,11 +333,11 @@ func (m model) tryAutoSelect() (resolvedSelection, bool) { } // If the profile supports model selection and the provider has multiple // models, don't auto-select — the user needs to pick a model. - if _, ok := p.(profiles.ModelSelector); ok && len(providers[0].Models) > 1 { + if wantsModelSelection(p, backends[0]) && len(providers[0].Models) > 1 { return resolvedSelection{}, false } selectedModel := "" - if len(providers[0].Models) == 1 { + if wantsModelSelection(p, backends[0]) && len(providers[0].Models) == 1 { selectedModel = providers[0].ID + "/" + providers[0].Models[0] } return resolvedSelection{ @@ -528,7 +528,7 @@ func (m model) resolveProviderAndExec() (model, tea.Cmd) { // proceedWithBackend resolves the model selection for a single backend and // either auto-launches or shows the model picker. func (m model) proceedWithBackend(b profiles.Backend) (model, tea.Cmd) { - _, wantsModel := m.chosenProfile.(profiles.ModelSelector) + wantsModel := wantsModelSelection(m.chosenProfile, b) if wantsModel && len(m.chosenProvider.Models) > 1 { m.chosenBackend = b @@ -539,7 +539,7 @@ func (m model) proceedWithBackend(b profiles.Backend) (model, tea.Cmd) { } // Auto-select the single model if available. - if len(m.chosenProvider.Models) == 1 { + if wantsModel && len(m.chosenProvider.Models) == 1 { m.selectedModel = m.chosenProvider.ID + "/" + m.chosenProvider.Models[0] } @@ -975,6 +975,19 @@ func fqnModels(p profiles.ProviderInfo) []string { return out } +// wantsModelSelection reports whether the TUI should show the model picker +// for the given (profile, backend). A profile must implement ModelSelector +// to opt in, and may further opt out per-backend via BackendModelSelector. +func wantsModelSelection(p profiles.Profile, b profiles.Backend) bool { + if _, ok := p.(profiles.ModelSelector); !ok { + return false + } + if g, ok := p.(profiles.BackendModelSelector); ok { + return g.WantsModelSelection(b) + } + return true +} + func containsString(items []string, item string) bool { for _, v := range items { if v == item { @@ -1015,7 +1028,7 @@ func (m *model) refreshLastSelection() { return } - selectedModel, ok := m.resolveLastModel(selectedProfile, provider) + selectedModel, ok := m.resolveLastModel(selectedProfile, selectedBackend, provider) if !ok { return } @@ -1059,8 +1072,8 @@ func (m model) resolveLastProvider(p profiles.Profile, b profiles.Backend) (prof return profiles.ProviderInfo{}, false } -func (m model) resolveLastModel(p profiles.Profile, provider profiles.ProviderInfo) (string, bool) { - if _, ok := p.(profiles.ModelSelector); !ok { +func (m model) resolveLastModel(p profiles.Profile, b profiles.Backend, provider profiles.ProviderInfo) (string, bool) { + if !wantsModelSelection(p, b) { return "", true } @@ -1098,7 +1111,7 @@ func (m model) checkAndExecSelectedBackend() (model, tea.Cmd) { // If the profile supports model selection and the provider has multiple // models, show the model picker instead of launching immediately. - _, wantsModel := m.chosenProfile.(profiles.ModelSelector) + wantsModel := wantsModelSelection(m.chosenProfile, b) if wantsModel && len(m.chosenProvider.Models) > 1 { m.chosenBackend = b m.modelItems = fqnModels(m.chosenProvider) From 38eb3ef44c765a76a64b2d180fca4dd5dba2269e Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Fri, 1 May 2026 03:04:15 +0000 Subject: [PATCH 11/15] Refactor profiles into per-client packages and app-level config Each agent CLI (Claude Code, Codex, Gemini, OpenCode) now lives in its own internal/clients sub-package that owns its install, launch, env, and config-writing logic end-to-end. The TUI becomes a generic menu-stack engine driven by internal/menu descriptors; app-level state (active Aperture endpoint, YOLO mode, last-launch record, provider list) moves to internal/config. Claude Desktop remains in internal/profiles for now with a thin adapter to the new clients.Client contract; a follow-up will port it out and delete the package. --- cmd/aperture/main.go | 24 +- internal/clients/binary.go | 77 + internal/clients/binary_test.go | 116 ++ internal/clients/claudecode/check.go | 55 + internal/clients/claudecode/check_test.go | 94 + internal/clients/claudecode/claudecode.go | 360 ++++ .../clients/claudecode/claudecode_test.go | 204 ++ internal/clients/claudecode/env.go | 65 + internal/clients/claudecode/install.go | 16 + internal/clients/codex/codex.go | 243 +++ internal/clients/codex/codex_test.go | 135 ++ internal/clients/codex/config.go | 49 + internal/clients/codex/install.go | 18 + internal/clients/gemini/config.go | 39 + internal/clients/gemini/gemini.go | 242 +++ internal/clients/gemini/gemini_test.go | 78 + internal/clients/gemini/install.go | 16 + internal/clients/launch.go | 66 + internal/clients/opencode/install.go | 17 + internal/clients/opencode/opencode.go | 242 +++ internal/clients/opencode/opencode_test.go | 205 +++ .../opencode.go => clients/opencode/sdk.go} | 116 +- internal/clients/registry.go | 90 + internal/config/client_config.go | 75 + internal/config/global.go | 114 ++ internal/config/providers.go | 18 + internal/{profiles => config}/settings.go | 20 +- internal/config/state.go | 84 + internal/config/state_test.go | 163 ++ internal/menu/menu.go | 74 + internal/menu/msgs.go | 21 + internal/profiles/adapter.go | 97 + internal/profiles/claude_code.go | 238 --- internal/profiles/claude_desktop_test.go | 21 + internal/profiles/codex.go | 121 -- internal/profiles/gemini_cli.go | 116 -- internal/profiles/profiles.go | 446 +---- internal/profiles/profiles_test.go | 1075 ----------- internal/tui/menus.go | 345 ++++ internal/tui/tui.go | 1640 ++++------------- internal/tui/tui_test.go | 232 ++- 41 files changed, 3984 insertions(+), 3483 deletions(-) create mode 100644 internal/clients/binary.go create mode 100644 internal/clients/binary_test.go create mode 100644 internal/clients/claudecode/check.go create mode 100644 internal/clients/claudecode/check_test.go create mode 100644 internal/clients/claudecode/claudecode.go create mode 100644 internal/clients/claudecode/claudecode_test.go create mode 100644 internal/clients/claudecode/env.go create mode 100644 internal/clients/claudecode/install.go create mode 100644 internal/clients/codex/codex.go create mode 100644 internal/clients/codex/codex_test.go create mode 100644 internal/clients/codex/config.go create mode 100644 internal/clients/codex/install.go create mode 100644 internal/clients/gemini/config.go create mode 100644 internal/clients/gemini/gemini.go create mode 100644 internal/clients/gemini/gemini_test.go create mode 100644 internal/clients/gemini/install.go create mode 100644 internal/clients/launch.go create mode 100644 internal/clients/opencode/install.go create mode 100644 internal/clients/opencode/opencode.go create mode 100644 internal/clients/opencode/opencode_test.go rename internal/{profiles/opencode.go => clients/opencode/sdk.go} (50%) create mode 100644 internal/clients/registry.go create mode 100644 internal/config/client_config.go create mode 100644 internal/config/global.go create mode 100644 internal/config/providers.go rename internal/{profiles => config}/settings.go (69%) create mode 100644 internal/config/state.go create mode 100644 internal/config/state_test.go create mode 100644 internal/menu/menu.go create mode 100644 internal/menu/msgs.go create mode 100644 internal/profiles/adapter.go delete mode 100644 internal/profiles/claude_code.go create mode 100644 internal/profiles/claude_desktop_test.go delete mode 100644 internal/profiles/codex.go delete mode 100644 internal/profiles/gemini_cli.go delete mode 100644 internal/profiles/profiles_test.go create mode 100644 internal/tui/menus.go diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index 83588aa..5489706 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -12,8 +12,15 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/profiles" "github.com/tailscale/aperture-cli/internal/tui" + + // Side-effect imports register each client with internal/clients. + _ "github.com/tailscale/aperture-cli/internal/clients/claudecode" + _ "github.com/tailscale/aperture-cli/internal/clients/codex" + _ "github.com/tailscale/aperture-cli/internal/clients/gemini" + _ "github.com/tailscale/aperture-cli/internal/clients/opencode" ) var ( @@ -111,16 +118,17 @@ func main() { os.Exit(0) } - settings, _ := profiles.LoadSettings() - state, _ := profiles.LoadState() - - // Use the first saved endpoint as the active host; fall back to the default. - host := "http://ai" - if len(settings.Endpoints) > 0 { - host = settings.Endpoints[0].URL + g, err := config.Load() + if err != nil { + slog.Error("loading launcher config", "err", err) + os.Exit(1) } + g.Debug = *flagDebug + + // Register Claude Desktop on supported platforms (darwin, windows). + profiles.RegisterIfSupported() - p := tea.NewProgram(tui.NewModel(host, settings, state, buildVersion, *flagDebug)) + p := tea.NewProgram(tui.NewModel(g, buildVersion)) if _, err := p.Run(); err != nil { slog.Error("launcher error", "err", err) os.Exit(1) diff --git a/internal/clients/binary.go b/internal/clients/binary.go new file mode 100644 index 0000000..6d44a63 --- /dev/null +++ b/internal/clients/binary.go @@ -0,0 +1,77 @@ +// Package clients holds the registry of AI coding agent clients (Claude Code, +// Codex, Gemini, OpenCode, ...). Each client owns its own install, launch, +// and configuration logic inside a sub-package; this file provides shared +// helpers for discovering client binaries on disk. +package clients + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// FindBinary returns the resolved path to a client binary. It checks +// exec.LookPath (i.e. $PATH) first, then the client-supplied extra paths, +// then general well-known user-local binary directories. Returns "" if the +// binary cannot be found. +func FindBinary(name string, extraPaths []string) string { + if name == "" { + return "" + } + if path, err := exec.LookPath(name); err == nil { + return path + } + for _, p := range extraPaths { + if isExecutable(p) { + return p + } + } + for _, dir := range commonBinDirs() { + p := filepath.Join(dir, name) + if isExecutable(p) { + return p + } + } + return "" +} + +// IsInstalled reports whether the named binary can be found on disk. +func IsInstalled(name string, extraPaths []string) bool { + if name == "" { + return true + } + return FindBinary(name, extraPaths) != "" +} + +// commonBinDirs returns well-known user-local directories that may not be on +// PATH yet (e.g. after a fresh install that updated shell profiles but the +// running shell still has the old PATH). System-wide directories are +// intentionally excluded: binaries there are found by exec.LookPath. +func commonBinDirs() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return []string{ + filepath.Join(home, ".local", "bin"), + filepath.Join(home, "bin"), + filepath.Join(home, ".npm-global", "bin"), + } +} + +func isExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + if info.IsDir() { + return false + } + if runtime.GOOS == "windows" { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".exe" || ext == ".cmd" || ext == ".bat" || ext == ".com" + } + return info.Mode()&0o111 != 0 +} diff --git a/internal/clients/binary_test.go b/internal/clients/binary_test.go new file mode 100644 index 0000000..fc31f16 --- /dev/null +++ b/internal/clients/binary_test.go @@ -0,0 +1,116 @@ +package clients_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/tailscale/aperture-cli/internal/clients" +) + +func TestFindBinary_PrefersPath(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + pathBin := filepath.Join(tmp, "pathbin") + if err := os.MkdirAll(pathBin, 0o755); err != nil { + t.Fatal(err) + } + pathBinary := filepath.Join(pathBin, "opencode") + if err := os.WriteFile(pathBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + commonBin := filepath.Join(tmp, ".opencode", "bin") + if err := os.MkdirAll(commonBin, 0o755); err != nil { + t.Fatal(err) + } + commonBinary := filepath.Join(commonBin, "opencode") + if err := os.WriteFile(commonBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv("PATH", pathBin) + + got := clients.FindBinary("opencode", []string{commonBinary}) + if got != pathBinary { + t.Errorf("FindBinary() = %q, want %q (PATH should be preferred)", got, pathBinary) + } +} + +func TestFindBinary_FallbackToExtraPaths(t *testing.T) { + tmp := t.TempDir() + t.Setenv("PATH", tmp) + t.Setenv("HOME", tmp) + + binDir := filepath.Join(tmp, ".opencode", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + fakeBinary := filepath.Join(binDir, "opencode") + if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + got := clients.FindBinary("opencode", []string{fakeBinary}) + if got != fakeBinary { + t.Errorf("FindBinary() = %q, want %q", got, fakeBinary) + } + if !clients.IsInstalled("opencode", []string{fakeBinary}) { + t.Error("IsInstalled() = false, want true") + } +} + +func TestFindBinary_FallbackToCommonBinDirs(t *testing.T) { + tmp := t.TempDir() + t.Setenv("PATH", tmp) + t.Setenv("HOME", tmp) + + localBin := filepath.Join(tmp, ".local", "bin") + if err := os.MkdirAll(localBin, 0o755); err != nil { + t.Fatal(err) + } + fakeBinary := filepath.Join(localBin, "claude") + if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + got := clients.FindBinary("claude", nil) + if got != fakeBinary { + t.Errorf("FindBinary() = %q, want %q", got, fakeBinary) + } +} + +func TestFindBinary_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("PATH", tmp) + t.Setenv("HOME", tmp) + + got := clients.FindBinary("claude", nil) + if got != "" { + t.Errorf("FindBinary() = %q, want empty", got) + } + if clients.IsInstalled("claude", nil) { + t.Error("IsInstalled() = true, want false") + } +} + +func TestFindBinary_SkipsNonExecutable(t *testing.T) { + tmp := t.TempDir() + t.Setenv("PATH", tmp) + t.Setenv("HOME", tmp) + + binDir := filepath.Join(tmp, ".local", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + nonExec := filepath.Join(binDir, "claude") + if err := os.WriteFile(nonExec, []byte("not executable"), 0o644); err != nil { + t.Fatal(err) + } + + got := clients.FindBinary("claude", nil) + if got != "" { + t.Errorf("FindBinary() = %q, want empty (not executable)", got) + } +} diff --git a/internal/clients/claudecode/check.go b/internal/clients/claudecode/check.go new file mode 100644 index 0000000..84795d2 --- /dev/null +++ b/internal/clients/claudecode/check.go @@ -0,0 +1,55 @@ +package claudecode + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// checkClaudeSettings validates that ~/.claude/settings.json does not set +// environment variables that conflict with what the launcher manages. +// Claude Code applies env from settings.json at startup, which would +// override the values the launcher injects via the process environment. +func checkClaudeSettings() error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cannot determine home directory: %w", err) + } + + settingsPath := filepath.Join(home, ".claude", "settings.json") + data, err := os.ReadFile(settingsPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("cannot read %s\n\nCheck file permissions and try again", settingsPath) + } + + var settings struct { + Env map[string]any `json:"env"` + } + if err := json.Unmarshal(data, &settings); err != nil { + return fmt.Errorf("%s contains invalid JSON\n\nFix the syntax or delete the file and let Claude Code recreate it", settingsPath) + } + if len(settings.Env) == 0 { + return nil + } + + var conflicts []string + for _, key := range managedEnvVars { + if _, ok := settings.Env[key]; ok { + conflicts = append(conflicts, key) + } + } + if len(conflicts) == 0 { + return nil + } + return fmt.Errorf( + "~/.claude/settings.json sets env vars that conflict with the launcher:\n\n %s\n\n"+ + "The launcher manages these variables automatically.\n"+ + "Remove them from the \"env\" section of ~/.claude/settings.json", + strings.Join(conflicts, "\n "), + ) +} diff --git a/internal/clients/claudecode/check_test.go b/internal/clients/claudecode/check_test.go new file mode 100644 index 0000000..9ace06b --- /dev/null +++ b/internal/clients/claudecode/check_test.go @@ -0,0 +1,94 @@ +package claudecode + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCheck_NoSettingsFile(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + if err := checkClaudeSettings(); err != nil { + t.Fatalf("Check returned error when settings.json missing: %v", err) + } +} + +func TestCheck_NoConflicts(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + claudeDir := filepath.Join(tmp, ".claude") + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), + []byte(`{"env":{"SOME_UNRELATED_VAR":"hello"}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := checkClaudeSettings(); err != nil { + t.Fatalf("Check returned error with no conflicting vars: %v", err) + } +} + +func TestCheck_WithConflicts(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + claudeDir := filepath.Join(tmp, ".claude") + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), + []byte(`{"env":{"ANTHROPIC_BASE_URL":"https://example.com","CLAUDE_CODE_USE_BEDROCK":"1"}}`), 0o644); err != nil { + t.Fatal(err) + } + err := checkClaudeSettings() + if err == nil { + t.Fatal("Check returned nil, expected error for conflicting vars") + } + msg := err.Error() + if !strings.Contains(msg, "ANTHROPIC_BASE_URL") { + t.Errorf("error should mention ANTHROPIC_BASE_URL, got: %s", msg) + } + if !strings.Contains(msg, "CLAUDE_CODE_USE_BEDROCK") { + t.Errorf("error should mention CLAUDE_CODE_USE_BEDROCK, got: %s", msg) + } +} + +func TestCheck_InvalidJSON(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + claudeDir := filepath.Join(tmp, ".claude") + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte("{not json}"), 0o644); err != nil { + t.Fatal(err) + } + err := checkClaudeSettings() + if err == nil { + t.Fatal("Check returned nil, expected error for invalid JSON") + } + if !strings.Contains(err.Error(), "invalid JSON") { + t.Errorf("error should mention invalid JSON, got: %s", err.Error()) + } +} + +func TestCheck_EmptyEnv(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + claudeDir := filepath.Join(tmp, ".claude") + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte(`{"env":{}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := checkClaudeSettings(); err != nil { + t.Fatalf("Check returned error with empty env: %v", err) + } +} diff --git a/internal/clients/claudecode/claudecode.go b/internal/clients/claudecode/claudecode.go new file mode 100644 index 0000000..bb0381a --- /dev/null +++ b/internal/clients/claudecode/claudecode.go @@ -0,0 +1,360 @@ +// Package claudecode is the Claude Code CLI client. It supports four routing +// flavors: Anthropic direct, AWS Bedrock, Google Vertex, and z.ai. The flow +// per launch is provider → backend → optional model (skipped for Bedrock, +// which resolves models from ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL +// env vars derived from the provider's model list at runtime) → Check → exec. +package claudecode + +import ( + "maps" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/menu" +) + +func init() { + clients.Register(&Client{}) +} + +// Client is the Claude Code CLI client. +type Client struct{} + +const ( + name = "Claude Code" + binaryName = "claude" +) + +// backend captures one of Claude Code's routing flavors. +type backend struct { + id string + displayName string + compatKeys []string + // picksModel is false for backends where the user does not pick a + // specific model (Bedrock: models are resolved per-tier at runtime). + picksModel bool +} + +var backends = []backend{ + {id: "anthropic", displayName: "Anthropic API", compatKeys: []string{"anthropic_messages"}, picksModel: true}, + {id: "bedrock", displayName: "AWS Bedrock", compatKeys: []string{"bedrock_model_invoke"}, picksModel: false}, + {id: "vertex", displayName: "Google Vertex", compatKeys: []string{"google_raw_predict"}, picksModel: true}, + {id: "zai", displayName: "z.ai", compatKeys: []string{"anthropic_messages"}, picksModel: true}, +} + +// Name implements clients.Client. +func (c *Client) Name() string { return name } + +// BinaryName implements clients.Client. +func (c *Client) BinaryName() string { return binaryName } + +// CommonPaths implements clients.Client. +func (c *Client) CommonPaths() []string { return commonBinaryPaths() } + +// IsInstalled implements clients.Client. +func (c *Client) IsInstalled() bool { + return clients.IsInstalled(binaryName, c.CommonPaths()) +} + +// Install implements clients.Client. +func (c *Client) Install(_ *config.Global) clients.InstallPlan { + return clients.InstallPlan{ + Hint: "curl -fsSL https://claude.ai/install.sh | bash", + Run: func() (*exec.Cmd, error) { + return exec.Command("/bin/sh", "-c", "curl -fsSL https://claude.ai/install.sh | bash"), nil + }, + } +} + +// Uninstall implements clients.Client. +func (c *Client) Uninstall() clients.UninstallPlan { + return clients.UninstallPlan{ + Hint: "rm -f ~/.local/bin/claude && rm -rf ~/.local/share/claude", + Run: func() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + os.Remove(filepath.Join(home, ".local", "bin", "claude")) + return os.RemoveAll(filepath.Join(home, ".local", "share", "claude")) + }, + } +} + +// Menu implements clients.Client. +func (c *Client) Menu(g *config.Global) menu.MenuItem { + return menu.MenuItem{ + Label: name, + Action: func() menu.Result { return c.providerStep(g) }, + } +} + +func (c *Client) providerStep(g *config.Global) menu.Result { + provs := compatibleProviders(g.Providers) + if len(provs) == 0 { + return errorResult("No providers support Claude Code.") + } + if len(provs) == 1 { + return c.backendStep(g, provs[0]) + } + items := make([]menu.MenuItem, 0, len(provs)) + for _, p := range provs { + items = append(items, menu.MenuItem{ + Label: p.DisplayName(), + Description: p.Description, + Action: func() menu.Result { return c.backendStep(g, p) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a provider for " + name + ":", + Items: items, + }} +} + +func (c *Client) backendStep(g *config.Global, p config.ProviderInfo) menu.Result { + bs := dedupedBackendsFor(p) + if len(bs) == 0 { + return errorResult("No compatible backends for " + p.DisplayName() + ".") + } + if len(bs) == 1 { + return c.modelStep(g, p, bs[0]) + } + items := make([]menu.MenuItem, 0, len(bs)) + for _, b := range bs { + items = append(items, menu.MenuItem{ + Label: b.displayName, + Action: func() menu.Result { return c.modelStep(g, p, b) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a backend for " + name + " via " + p.DisplayName() + ":", + Items: items, + }} +} + +func (c *Client) modelStep(g *config.Global, p config.ProviderInfo, b backend) menu.Result { + if !b.picksModel || len(p.Models) <= 1 { + var m string + if b.picksModel && len(p.Models) == 1 { + m = p.ID + "/" + p.Models[0] + } + return c.launch(g, p, b, m) + } + models := fqnModels(p) + items := make([]menu.MenuItem, 0, len(models)) + for _, m := range models { + items = append(items, menu.MenuItem{ + Label: m, + Action: func() menu.Result { return c.launch(g, p, b, m) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a default model for " + name + " via " + p.DisplayName() + ":", + Items: items, + }} +} + +func (c *Client) launch(g *config.Global, p config.ProviderInfo, b backend, model string) menu.Result { + if err := checkClaudeSettings(); err != nil { + return errorResult(err.Error()) + } + bin := clients.FindBinary(binaryName, c.CommonPaths()) + if bin == "" { + bin = binaryName + } + env, err := envForBackend(g.ApertureHost, b) + if err != nil { + return errorResult(err.Error()) + } + // Bedrock derives per-tier model env vars from the provider's model list. + maps.Copy(env, tierModelEnv(b, p)) + if model != "" { + applyModel(model, env) + } + + var args []string + if g.Settings.YoloMode { + args = append(args, "--dangerously-skip-permissions") + } + + _ = g.RecordLaunch(config.LaunchState{ + LastClientName: name, + LastBackendType: b.id, + LastProviderID: p.ID, + LastModel: model, + }) + + cmd := clients.Launch(clients.LaunchSpec{ + Binary: bin, + Args: args, + Env: env, + Debug: g.Debug, + }) + return menu.Result{Cmd: cmd, PopOnDone: true} +} + +// Replay implements clients.Client. +func (c *Client) Replay(g *config.Global) tea.Cmd { + if g.LastLaunch.LastClientName != name || !c.IsInstalled() { + return nil + } + prov, ok := g.Provider(g.LastLaunch.LastProviderID) + if !ok { + return nil + } + idx := slices.IndexFunc(backends, func(b backend) bool { + return b.id == g.LastLaunch.LastBackendType + }) + if idx < 0 { + return nil + } + b := backends[idx] + if !backendMatches(prov, b) { + return nil + } + model := g.LastLaunch.LastModel + if b.picksModel && model != "" && !slices.Contains(fqnModels(prov), model) { + return nil + } + res := c.launch(g, prov, b, model) + return res.Cmd +} + +// QuickSelectLabel implements clients.Client. +func (c *Client) QuickSelectLabel(g *config.Global) string { + prov, _ := g.Provider(g.LastLaunch.LastProviderID) + b := g.LastLaunch.LastBackendType + for _, bb := range backends { + if bb.id == b { + b = bb.displayName + break + } + } + label := name + " via " + prov.DisplayName() + " - " + b + if g.LastLaunch.LastModel != "" { + label += " - " + g.LastLaunch.LastModel + } + return label +} + +// compatibleProviders returns providers that can service any Claude Code +// backend, deduplicating across backends that share a compat key. +func compatibleProviders(all []config.ProviderInfo) []config.ProviderInfo { + var out []config.ProviderInfo + for _, p := range all { + if len(backendsFor(p)) > 0 { + out = append(out, p) + } + } + return out +} + +// backendsFor returns every backend the provider's compat map supports, +// without dedup (Anthropic and z.ai both take "anthropic_messages"). +func backendsFor(p config.ProviderInfo) []backend { + var out []backend + for _, b := range backends { + if backendMatches(p, b) { + out = append(out, b) + } + } + return out +} + +// dedupedBackendsFor returns backends for p, dropping ones that share a +// compat signature with an earlier backend (keeps Anthropic, drops z.ai +// when both match "anthropic_messages"). The user sees one row per +// functionally distinct routing option. +func dedupedBackendsFor(p config.ProviderInfo) []backend { + raw := backendsFor(p) + seen := make(map[string]bool) + var out []backend + for _, b := range raw { + sig := strings.Join(b.compatKeys, ",") + if seen[sig] { + continue + } + seen[sig] = true + out = append(out, b) + } + return out +} + +func backendMatches(p config.ProviderInfo, b backend) bool { + for _, k := range b.compatKeys { + if p.Compatibility[k] { + return true + } + } + return false +} + +func fqnModels(p config.ProviderInfo) []string { + out := make([]string, len(p.Models)) + for i, m := range p.Models { + out[i] = p.ID + "/" + m + } + return out +} + +// applyModel writes the user-chosen model (FQN "provider/model") to the env. +// The provider prefix is stripped because Bedrock's URL path embeds the +// model and a stray prefix would break routing (e.g. /bedrock/model/bedrock/.../invoke). +func applyModel(fqn string, env map[string]string) { + model := fqn + if _, after, ok := strings.Cut(fqn, "/"); ok { + model = after + } + env["ANTHROPIC_MODEL"] = model +} + +// tierModelEnv derives ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL from the +// provider's model list when the backend does not pick a specific model +// (i.e. Bedrock). For z.ai, Env already sets fixed model names so we return +// nil. For all other backends this is a no-op. +func tierModelEnv(b backend, p config.ProviderInfo) map[string]string { + if b.id != "bedrock" { + return nil + } + if !backendMatches(p, b) { + return nil + } + + models := slices.Clone(p.Models) + env := make(map[string]string) + targets := []struct { + substr string + envKey string + }{ + {"opus", "ANTHROPIC_DEFAULT_OPUS_MODEL"}, + {"sonnet", "ANTHROPIC_DEFAULT_SONNET_MODEL"}, + {"haiku", "ANTHROPIC_DEFAULT_HAIKU_MODEL"}, + } + sort.Sort(sort.Reverse(sort.StringSlice(models))) + for _, m := range models { + lower := strings.ToLower(m) + for _, t := range targets { + if _, ok := env[t.envKey]; !ok && strings.Contains(lower, t.substr) { + env[t.envKey] = m + } + } + } + return env +} + +func errorResult(msg string) menu.Result { + return menu.Result{Cmd: func() tea.Msg { + return menu.SimpleDoneMsg{Err: errString(msg)} + }} +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/internal/clients/claudecode/claudecode_test.go b/internal/clients/claudecode/claudecode_test.go new file mode 100644 index 0000000..dd900f5 --- /dev/null +++ b/internal/clients/claudecode/claudecode_test.go @@ -0,0 +1,204 @@ +package claudecode + +import ( + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +const testHost = "http://ai.example.com" + +func TestEnv_Anthropic(t *testing.T) { + env, err := envForBackend(testHost, backends[0]) + if err != nil { + t.Fatal(err) + } + if env["ANTHROPIC_BASE_URL"] != testHost { + t.Errorf("ANTHROPIC_BASE_URL = %q", env["ANTHROPIC_BASE_URL"]) + } + if env["ANTHROPIC_AUTH_TOKEN"] != "-" { + t.Errorf("ANTHROPIC_AUTH_TOKEN = %q", env["ANTHROPIC_AUTH_TOKEN"]) + } +} + +func TestEnv_Bedrock(t *testing.T) { + b := lookupBackend("bedrock") + env, err := envForBackend(testHost, b) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + "ANTHROPIC_BEDROCK_BASE_URL": testHost + "/bedrock", + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1", + } + for k, v := range want { + if env[k] != v { + t.Errorf("%s = %q, want %q", k, env[k], v) + } + } +} + +func TestEnv_Vertex(t *testing.T) { + b := lookupBackend("vertex") + env, err := envForBackend(testHost, b) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + "CLOUD_ML_REGION": "_aperture_auto_vertex_region_", + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_VERTEX_PROJECT_ID": "_aperture_auto_vertex_project_id_", + "ANTHROPIC_VERTEX_BASE_URL": testHost + "/v1", + } + for k, v := range want { + if env[k] != v { + t.Errorf("%s = %q, want %q", k, env[k], v) + } + } +} + +func TestEnv_ZAI(t *testing.T) { + b := lookupBackend("zai") + env, err := envForBackend(testHost, b) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + "ANTHROPIC_BASE_URL": testHost, + "ANTHROPIC_MODEL": "glm-5.1", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.1", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5-turbo", + "API_TIMEOUT_MS": "3000000", + "ANTHROPIC_API_KEY": "-", + } + for k, v := range want { + if env[k] != v { + t.Errorf("%s = %q, want %q", k, env[k], v) + } + } +} + +func TestApplyModel_StripsProviderPrefix(t *testing.T) { + env := map[string]string{} + applyModel("bedrock/anthropic.claude-opus-4-7", env) + if env["ANTHROPIC_MODEL"] != "anthropic.claude-opus-4-7" { + t.Errorf("ANTHROPIC_MODEL = %q, want %q", env["ANTHROPIC_MODEL"], "anthropic.claude-opus-4-7") + } +} + +func TestApplyModel_Bare(t *testing.T) { + env := map[string]string{} + applyModel("claude-sonnet-4-20250514", env) + if env["ANTHROPIC_MODEL"] != "claude-sonnet-4-20250514" { + t.Errorf("ANTHROPIC_MODEL = %q", env["ANTHROPIC_MODEL"]) + } +} + +func TestBackendsFor_Anthropic(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{"anthropic_messages": true}} + got := backendsFor(p) + // anthropic + zai both take anthropic_messages. + if len(got) != 2 { + t.Errorf("backendsFor = %+v", got) + } +} + +func TestDedupedBackendsFor_AnthropicVsZAI(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{"anthropic_messages": true}} + got := dedupedBackendsFor(p) + if len(got) != 1 || got[0].id != "anthropic" { + t.Errorf("dedupedBackendsFor = %+v, want [anthropic]", got) + } +} + +func TestDedupedBackendsFor_Multi(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{ + "anthropic_messages": true, + "bedrock_model_invoke": true, + }} + got := dedupedBackendsFor(p) + if len(got) != 2 { + t.Errorf("dedupedBackendsFor = %+v, want 2", got) + } +} + +func TestCompatibleProviders(t *testing.T) { + provs := []config.ProviderInfo{ + {ID: "anthropic", Compatibility: map[string]bool{"anthropic_messages": true}}, + {ID: "bedrock", Compatibility: map[string]bool{"bedrock_model_invoke": true}}, + {ID: "openai-only", Compatibility: map[string]bool{"openai_chat": true}}, + } + got := compatibleProviders(provs) + if len(got) != 2 { + t.Errorf("compatibleProviders = %+v", got) + } +} + +func TestTierModelEnv_Bedrock(t *testing.T) { + b := lookupBackend("bedrock") + p := config.ProviderInfo{ + Models: []string{ + "us.anthropic.claude-opus-4-1-20250805-v1:0", + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us.anthropic.claude-haiku-4-5-20251001-v1:0", + }, + Compatibility: map[string]bool{"bedrock_model_invoke": true}, + } + env := tierModelEnv(b, p) + if !containsSubstr(env["ANTHROPIC_DEFAULT_OPUS_MODEL"], "opus") { + t.Errorf("OPUS tier = %q", env["ANTHROPIC_DEFAULT_OPUS_MODEL"]) + } + if !containsSubstr(env["ANTHROPIC_DEFAULT_SONNET_MODEL"], "sonnet") { + t.Errorf("SONNET tier = %q", env["ANTHROPIC_DEFAULT_SONNET_MODEL"]) + } + if !containsSubstr(env["ANTHROPIC_DEFAULT_HAIKU_MODEL"], "haiku") { + t.Errorf("HAIKU tier = %q", env["ANTHROPIC_DEFAULT_HAIKU_MODEL"]) + } +} + +func TestTierModelEnv_NonBedrock(t *testing.T) { + b := lookupBackend("anthropic") + p := config.ProviderInfo{ + Models: []string{"claude-opus-4", "claude-sonnet-4"}, + Compatibility: map[string]bool{"anthropic_messages": true}, + } + env := tierModelEnv(b, p) + if len(env) != 0 { + t.Errorf("tierModelEnv(anthropic) = %+v, want empty", env) + } +} + +func lookupBackend(id string) backend { + for _, b := range backends { + if b.id == id { + return b + } + } + panic("unknown backend id: " + id) +} + +func containsSubstr(s, sub string) bool { + if s == "" { + return false + } + for i := 0; i+len(sub) <= len(s); i++ { + if lower(s[i:i+len(sub)]) == sub { + return true + } + } + return false +} + +func lower(s string) string { + b := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + b[i] = c + } + return string(b) +} diff --git a/internal/clients/claudecode/env.go b/internal/clients/claudecode/env.go new file mode 100644 index 0000000..eb94d13 --- /dev/null +++ b/internal/clients/claudecode/env.go @@ -0,0 +1,65 @@ +package claudecode + +import ( + "fmt" +) + +// envForBackend returns the environment variables that route Claude Code +// through the aperture gateway for the chosen backend. +func envForBackend(apertureHost string, b backend) (map[string]string, error) { + switch b.id { + case "anthropic": + return map[string]string{ + "ANTHROPIC_BASE_URL": apertureHost, + "ANTHROPIC_AUTH_TOKEN": "-", + }, nil + case "bedrock": + return map[string]string{ + "ANTHROPIC_BEDROCK_BASE_URL": apertureHost + "/bedrock", + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1", + }, nil + case "vertex": + return map[string]string{ + "CLOUD_ML_REGION": "_aperture_auto_vertex_region_", + "CLAUDE_CODE_USE_VERTEX": "1", + "CLAUDE_CODE_SKIP_VERTEX_AUTH": "1", + "ANTHROPIC_VERTEX_PROJECT_ID": "_aperture_auto_vertex_project_id_", + "ANTHROPIC_VERTEX_BASE_URL": apertureHost + "/v1", + }, nil + case "zai": + return map[string]string{ + "ANTHROPIC_BASE_URL": apertureHost, + "ANTHROPIC_MODEL": "glm-5.1", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.1", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5-turbo", + "API_TIMEOUT_MS": "3000000", + "ANTHROPIC_API_KEY": "-", + }, nil + default: + return nil, fmt.Errorf("unsupported backend %q for Claude Code", b.id) + } +} + +// managedEnvVars is every environment variable name the launcher may set +// for Claude Code. Check() uses this list to warn when the user's +// ~/.claude/settings.json would override them. +var managedEnvVars = []string{ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BEDROCK_BASE_URL", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_SKIP_BEDROCK_AUTH", + "CLOUD_ML_REGION", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_SKIP_VERTEX_AUTH", + "ANTHROPIC_VERTEX_PROJECT_ID", + "ANTHROPIC_VERTEX_BASE_URL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "API_TIMEOUT_MS", + "ANTHROPIC_API_KEY", +} diff --git a/internal/clients/claudecode/install.go b/internal/clients/claudecode/install.go new file mode 100644 index 0000000..965aa26 --- /dev/null +++ b/internal/clients/claudecode/install.go @@ -0,0 +1,16 @@ +package claudecode + +import ( + "os" + "path/filepath" +) + +func commonBinaryPaths() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return []string{ + filepath.Join(home, ".local", "bin", "claude"), + } +} diff --git a/internal/clients/codex/codex.go b/internal/clients/codex/codex.go new file mode 100644 index 0000000..569c12d --- /dev/null +++ b/internal/clients/codex/codex.go @@ -0,0 +1,243 @@ +// Package codex is the OpenAI Codex client. It speaks OpenAI's /v1/responses +// API and is registered only with providers whose compatibility map includes +// "openai_responses". On launch it writes a CODEX_HOME containing auth.json +// (pre-populated so the first run skips interactive login) and config.toml +// (pointing Codex at the aperture gateway). +package codex + +import ( + "os/exec" + "slices" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/menu" +) + +func init() { + clients.Register(&Client{}) +} + +// Client is the OpenAI Codex client. +type Client struct{} + +const ( + name = "OpenAI Codex" + binaryName = "codex" + compatKey = "openai_responses" +) + +// Name implements clients.Client. +func (c *Client) Name() string { return name } + +// BinaryName implements clients.Client. +func (c *Client) BinaryName() string { return binaryName } + +// CommonPaths implements clients.Client. +func (c *Client) CommonPaths() []string { + return commonBinaryPaths() +} + +// IsInstalled implements clients.Client. +func (c *Client) IsInstalled() bool { + return clients.IsInstalled(binaryName, c.CommonPaths()) +} + +// Install implements clients.Client. +func (c *Client) Install(_ *config.Global) clients.InstallPlan { + return clients.InstallPlan{ + Hint: "npm install -g @openai/codex", + Run: func() (*exec.Cmd, error) { + return exec.Command("/bin/sh", "-c", "npm install -g @openai/codex"), nil + }, + } +} + +// Uninstall implements clients.Client. +func (c *Client) Uninstall() clients.UninstallPlan { + return clients.UninstallPlan{ + Hint: "npm uninstall -g @openai/codex", + Run: func() error { + return exec.Command("npm", "uninstall", "-g", "@openai/codex").Run() + }, + } +} + +// Menu implements clients.Client. Codex speaks only OpenAI /v1/responses, +// so the flow is: pick a compatible provider → pick a model → launch. +func (c *Client) Menu(g *config.Global) menu.MenuItem { + return menu.MenuItem{ + Label: name, + Action: func() menu.Result { + return c.providerStep(g) + }, + } +} + +// providerStep builds the provider menu, or descends directly if only one +// provider is compatible. +func (c *Client) providerStep(g *config.Global) menu.Result { + provs := compatibleProviders(g.Providers) + if len(provs) == 0 { + return errorResult("No providers support OpenAI /v1/responses.") + } + if len(provs) == 1 { + return c.modelStep(g, provs[0]) + } + items := make([]menu.MenuItem, 0, len(provs)) + for _, p := range provs { + items = append(items, menu.MenuItem{ + Label: p.DisplayName(), + Description: p.Description, + Action: func() menu.Result { return c.modelStep(g, p) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a provider for " + name + ":", + Items: items, + }} +} + +// modelStep shows the model picker when the provider has multiple models, +// or descends straight to launch with the single model. +func (c *Client) modelStep(g *config.Global, p config.ProviderInfo) menu.Result { + models := fqnModels(p) + if len(models) <= 1 { + var m string + if len(models) == 1 { + m = models[0] + } + return c.launch(g, p, m) + } + items := make([]menu.MenuItem, 0, len(models)) + for _, m := range models { + items = append(items, menu.MenuItem{ + Label: m, + Action: func() menu.Result { return c.launch(g, p, m) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a default model for " + name + " via " + p.DisplayName() + ":", + Items: items, + }} +} + +// launch writes CODEX_HOME, builds the exec spec, records the launch state, +// and returns a tea.Cmd. +func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) menu.Result { + bin := clients.FindBinary(binaryName, c.CommonPaths()) + if bin == "" { + bin = binaryName + } + codexHome, err := writeConfig(g.ApertureHost) + if err != nil { + return errorResult("Failed to write Codex config: " + err.Error()) + } + env := map[string]string{ + "OPENAI_BASE_URL": g.ApertureHost + "/v1", + "OPENAI_API_KEY": "not-needed", + "CODEX_HOME": codexHome, + } + if model != "" { + env["OPENAI_MODEL"] = stripProviderPrefix(model) + } + + args := []string{} + if model != "" { + args = append(args, "--model", model) + } + if g.Settings.YoloMode { + args = append(args, "--dangerously-bypass-approvals-and-sandbox") + } + + _ = g.RecordLaunch(config.LaunchState{ + LastClientName: name, + LastBackendType: "openai", + LastProviderID: p.ID, + LastModel: model, + }) + + cmd := clients.Launch(clients.LaunchSpec{ + Binary: bin, + Args: args, + Env: env, + Debug: g.Debug, + }) + return menu.Result{Cmd: cmd, PopOnDone: true} +} + +// Replay implements clients.Client. +func (c *Client) Replay(g *config.Global) tea.Cmd { + if g.LastLaunch.LastClientName != name { + return nil + } + if !c.IsInstalled() { + return nil + } + prov, ok := g.Provider(g.LastLaunch.LastProviderID) + if !ok { + return nil + } + if !prov.Compatibility[compatKey] { + return nil + } + model := g.LastLaunch.LastModel + if model != "" && !slices.Contains(fqnModels(prov), model) { + return nil + } + // Launch. The Cmd inside Result is a tea.Cmd, so unwrap. + res := c.launch(g, prov, model) + return res.Cmd +} + +// QuickSelectLabel implements clients.Client. +func (c *Client) QuickSelectLabel(g *config.Global) string { + prov, _ := g.Provider(g.LastLaunch.LastProviderID) + label := name + " via " + prov.DisplayName() + " - OpenAI Compatible" + if g.LastLaunch.LastModel != "" { + label += " - " + g.LastLaunch.LastModel + } + return label +} + +// compatibleProviders returns the subset of providers that Codex can use. +func compatibleProviders(all []config.ProviderInfo) []config.ProviderInfo { + var out []config.ProviderInfo + for _, p := range all { + if p.Compatibility[compatKey] { + out = append(out, p) + } + } + return out +} + +// fqnModels returns the provider's models in "provider_id/model_id" form. +func fqnModels(p config.ProviderInfo) []string { + out := make([]string, len(p.Models)) + for i, m := range p.Models { + out[i] = p.ID + "/" + m + } + return out +} + +func stripProviderPrefix(fqn string) string { + if _, after, ok := strings.Cut(fqn, "/"); ok { + return after + } + return fqn +} + +// errorResult returns a Result that pops the current stack and emits an +// error via the TUI's generic error mechanism. The TUI interprets a Cmd +// that returns an error-bearing SimpleDoneMsg as "show this error". +func errorResult(msg string) menu.Result { + return menu.Result{Cmd: func() tea.Msg { + return menu.SimpleDoneMsg{Err: errString(msg)} + }, PopOnDone: false} +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/internal/clients/codex/codex_test.go b/internal/clients/codex/codex_test.go new file mode 100644 index 0000000..706c10e --- /dev/null +++ b/internal/clients/codex/codex_test.go @@ -0,0 +1,135 @@ +package codex + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +const testHost = "http://ai.example.com" + +func TestCompatibleProviders(t *testing.T) { + provs := []config.ProviderInfo{ + {ID: "openai", Compatibility: map[string]bool{"openai_responses": true}}, + {ID: "openrouter", Compatibility: map[string]bool{"openai_chat": true}}, + {ID: "anthropic", Compatibility: map[string]bool{"anthropic_messages": true}}, + } + got := compatibleProviders(provs) + if len(got) != 1 || got[0].ID != "openai" { + t.Errorf("compatibleProviders = %+v, want [openai]", got) + } +} + +func TestFqnModels(t *testing.T) { + p := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5", "gpt-5-mini"}} + got := fqnModels(p) + want := []string{"openai/gpt-5", "openai/gpt-5-mini"} + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Errorf("fqnModels = %v, want %v", got, want) + } +} + +func TestStripProviderPrefix(t *testing.T) { + cases := map[string]string{ + "openai/gpt-5": "gpt-5", + "vertex/gemini-2.5-pro": "gemini-2.5-pro", + "bare-model": "bare-model", + "provider/nested/model": "nested/model", + } + for in, want := range cases { + if got := stripProviderPrefix(in); got != want { + t.Errorf("stripProviderPrefix(%q) = %q, want %q", in, got, want) + } + } +} + +func TestWriteConfig(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + codexHome, err := writeConfig(testHost) + if err != nil { + t.Fatalf("writeConfig: %v", err) + } + + authData, err := os.ReadFile(filepath.Join(codexHome, "auth.json")) + if err != nil { + t.Fatalf("auth.json: %v", err) + } + var auth map[string]string + if err := json.Unmarshal(authData, &auth); err != nil { + t.Fatal(err) + } + if auth["auth_mode"] != "apikey" { + t.Errorf("auth_mode = %q, want apikey", auth["auth_mode"]) + } + if auth["OPENAI_API_KEY"] != "not-needed" { + t.Errorf("OPENAI_API_KEY = %q, want not-needed", auth["OPENAI_API_KEY"]) + } + + tomlData, err := os.ReadFile(filepath.Join(codexHome, "config.toml")) + if err != nil { + t.Fatalf("config.toml: %v", err) + } + if got := string(tomlData); !containsAll(got, []string{ + "model_provider = \"aperture\"", + "base_url = \"" + testHost + "/v1\"", + "env_key = \"OPENAI_API_KEY\"", + }) { + t.Errorf("config.toml missing expected entries:\n%s", got) + } +} + +func TestInstallUninstall(t *testing.T) { + c := &Client{} + g := &config.Global{} + + install := c.Install(g) + if install.Hint != "npm install -g @openai/codex" { + t.Errorf("Install.Hint = %q", install.Hint) + } + if install.Run == nil { + t.Error("Install.Run is nil") + } + + uninstall := c.Uninstall() + if uninstall.Hint != "npm uninstall -g @openai/codex" { + t.Errorf("Uninstall.Hint = %q", uninstall.Hint) + } +} + +func TestReplay_StaleProvider(t *testing.T) { + c := &Client{} + g := &config.Global{ + LastLaunch: config.LaunchState{ + LastClientName: name, + LastProviderID: "missing", + }, + } + // Binary not installed → nil regardless of provider presence. + if cmd := c.Replay(g); cmd != nil { + t.Error("Replay with missing binary should return nil") + } +} + +func containsAll(haystack string, needles []string) bool { + for _, n := range needles { + if !contains(haystack, n) { + return false + } + } + return true +} + +func contains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/internal/clients/codex/config.go b/internal/clients/codex/config.go new file mode 100644 index 0000000..0f0c284 --- /dev/null +++ b/internal/clients/codex/config.go @@ -0,0 +1,49 @@ +package codex + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + + "github.com/tailscale/aperture-cli/internal/config" +) + +// writeConfig creates (or refreshes) the persistent CODEX_HOME directory +// holding auth.json and config.toml. Returns the directory path suitable +// for the CODEX_HOME environment variable. +// +// auth.json is pre-populated so Codex's first-run login prompt is skipped. +// config.toml pins the model provider to "aperture" pointing at the current +// aperture gateway. +func writeConfig(apertureHost string) (string, error) { + codexHome, err := config.ClientConfigDir("codex") + if err != nil { + return "", err + } + + auth := map[string]any{ + "auth_mode": "apikey", + "OPENAI_API_KEY": "not-needed", + } + data, err := json.MarshalIndent(auth, "", " ") + if err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(codexHome, "auth.json"), data, 0o600); err != nil { + return "", err + } + + baseURL := apertureHost + "/v1" + cfg := "model_provider = \"aperture\"\n\n" + + "[model_providers.aperture]\n" + + "name = \"Aperture\"\n" + + "base_url = " + strconv.Quote(baseURL) + "\n" + + "env_key = \"OPENAI_API_KEY\"\n" + + "supports_websockets = false\n" + if err := os.WriteFile(filepath.Join(codexHome, "config.toml"), []byte(cfg), 0o600); err != nil { + return "", err + } + + return codexHome, nil +} diff --git a/internal/clients/codex/install.go b/internal/clients/codex/install.go new file mode 100644 index 0000000..186ed0f --- /dev/null +++ b/internal/clients/codex/install.go @@ -0,0 +1,18 @@ +package codex + +import ( + "os" + "path/filepath" +) + +// commonBinaryPaths returns the non-PATH locations where `codex` is +// commonly installed. +func commonBinaryPaths() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return []string{ + filepath.Join(home, ".local", "bin", "codex"), + } +} diff --git a/internal/clients/gemini/config.go b/internal/clients/gemini/config.go new file mode 100644 index 0000000..51adee9 --- /dev/null +++ b/internal/clients/gemini/config.go @@ -0,0 +1,39 @@ +package gemini + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/tailscale/aperture-cli/internal/config" +) + +// writeConfig creates a persistent GEMINI_CLI_HOME whose +// /.gemini/settings.json selects the auth type matching the chosen +// backend (vertex-ai vs gemini-api-key). Returns the home path to hand to +// the agent via the GEMINI_CLI_HOME env var. +func writeConfig(selectedAuthType string) (string, error) { + geminiHome, err := config.ClientConfigDir("gemini") + if err != nil { + return "", err + } + geminiDir := filepath.Join(geminiHome, ".gemini") + if err := os.MkdirAll(geminiDir, 0o700); err != nil { + return "", err + } + settings := map[string]any{ + "security": map[string]any{ + "auth": map[string]any{ + "selectedType": selectedAuthType, + }, + }, + } + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(geminiDir, "settings.json"), data, 0o600); err != nil { + return "", err + } + return geminiHome, nil +} diff --git a/internal/clients/gemini/gemini.go b/internal/clients/gemini/gemini.go new file mode 100644 index 0000000..bff9f9f --- /dev/null +++ b/internal/clients/gemini/gemini.go @@ -0,0 +1,242 @@ +// Package gemini is the Google Gemini CLI client. It supports two routing +// flavors — Vertex AI (when a provider exposes experimental Gemini-on-Vertex +// compatibility) and the Gemini API — and writes a GEMINI_CLI_HOME whose +// settings.json selects the matching auth type for the chosen flavor. +package gemini + +import ( + "os/exec" + "slices" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/menu" +) + +func init() { + clients.Register(&Client{}) +} + +// Client is the Gemini CLI client. +type Client struct{} + +const ( + name = "Gemini CLI" + binaryName = "gemini" +) + +// backend captures one of Gemini CLI's routing flavors. +type backend struct { + id string + displayName string + compatKey string + authType string +} + +var backends = []backend{ + { + id: "vertex", + displayName: "Google Vertex", + compatKey: "experimental_gemini_cli_vertex_compat", + authType: "vertex-ai", + }, + { + id: "gemini", + displayName: "Gemini API", + compatKey: "gemini_generate_content", + authType: "gemini-api-key", + }, +} + +// Name implements clients.Client. +func (c *Client) Name() string { return name } + +// BinaryName implements clients.Client. +func (c *Client) BinaryName() string { return binaryName } + +// CommonPaths implements clients.Client. +func (c *Client) CommonPaths() []string { return commonBinaryPaths() } + +// IsInstalled implements clients.Client. +func (c *Client) IsInstalled() bool { + return clients.IsInstalled(binaryName, c.CommonPaths()) +} + +// Install implements clients.Client. +func (c *Client) Install(_ *config.Global) clients.InstallPlan { + return clients.InstallPlan{ + Hint: "npm install -g @google/gemini-cli", + Run: func() (*exec.Cmd, error) { + return exec.Command("/bin/sh", "-c", "npm install -g @google/gemini-cli"), nil + }, + } +} + +// Uninstall implements clients.Client. +func (c *Client) Uninstall() clients.UninstallPlan { + return clients.UninstallPlan{ + Hint: "npm uninstall -g @google/gemini-cli", + Run: func() error { + return exec.Command("npm", "uninstall", "-g", "@google/gemini-cli").Run() + }, + } +} + +// Menu implements clients.Client. +func (c *Client) Menu(g *config.Global) menu.MenuItem { + return menu.MenuItem{ + Label: name, + Action: func() menu.Result { return c.providerStep(g) }, + } +} + +func (c *Client) providerStep(g *config.Global) menu.Result { + provs := compatibleProviders(g.Providers) + if len(provs) == 0 { + return errorResult("No providers support Gemini CLI (Vertex or Gemini API).") + } + if len(provs) == 1 { + return c.backendStep(g, provs[0]) + } + items := make([]menu.MenuItem, 0, len(provs)) + for _, p := range provs { + items = append(items, menu.MenuItem{ + Label: p.DisplayName(), + Description: p.Description, + Action: func() menu.Result { return c.backendStep(g, p) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a provider for " + name + ":", + Items: items, + }} +} + +func (c *Client) backendStep(g *config.Global, p config.ProviderInfo) menu.Result { + bs := backendsFor(p) + if len(bs) == 0 { + return errorResult("No compatible backends for " + p.DisplayName() + ".") + } + if len(bs) == 1 { + return c.launch(g, p, bs[0]) + } + items := make([]menu.MenuItem, 0, len(bs)) + for _, b := range bs { + items = append(items, menu.MenuItem{ + Label: b.displayName, + Action: func() menu.Result { return c.launch(g, p, b) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a backend for " + name + " via " + p.DisplayName() + ":", + Items: items, + }} +} + +func (c *Client) launch(g *config.Global, p config.ProviderInfo, b backend) menu.Result { + bin := clients.FindBinary(binaryName, c.CommonPaths()) + if bin == "" { + bin = binaryName + } + geminiHome, err := writeConfig(b.authType) + if err != nil { + return errorResult("Failed to write Gemini config: " + err.Error()) + } + env := map[string]string{ + "GEMINI_CLI_HOME": geminiHome, + } + switch b.id { + case "vertex": + env["GOOGLE_VERTEX_BASE_URL"] = g.ApertureHost + env["GOOGLE_API_KEY"] = "not-needed" + case "gemini": + env["GEMINI_API_KEY"] = "not-needed" + env["GEMINI_BASE_URL"] = g.ApertureHost + } + + var args []string + if g.Settings.YoloMode { + args = append(args, "--yolo") + } + + _ = g.RecordLaunch(config.LaunchState{ + LastClientName: name, + LastBackendType: b.id, + LastProviderID: p.ID, + }) + + cmd := clients.Launch(clients.LaunchSpec{ + Binary: bin, + Args: args, + Env: env, + Debug: g.Debug, + }) + return menu.Result{Cmd: cmd, PopOnDone: true} +} + +// Replay implements clients.Client. +func (c *Client) Replay(g *config.Global) tea.Cmd { + if g.LastLaunch.LastClientName != name || !c.IsInstalled() { + return nil + } + prov, ok := g.Provider(g.LastLaunch.LastProviderID) + if !ok { + return nil + } + idx := slices.IndexFunc(backends, func(b backend) bool { + return b.id == g.LastLaunch.LastBackendType + }) + if idx < 0 { + return nil + } + b := backends[idx] + if !prov.Compatibility[b.compatKey] { + return nil + } + res := c.launch(g, prov, b) + return res.Cmd +} + +// QuickSelectLabel implements clients.Client. +func (c *Client) QuickSelectLabel(g *config.Global) string { + prov, _ := g.Provider(g.LastLaunch.LastProviderID) + b := g.LastLaunch.LastBackendType + for _, bb := range backends { + if bb.id == b { + b = bb.displayName + break + } + } + return name + " via " + prov.DisplayName() + " - " + b +} + +func compatibleProviders(all []config.ProviderInfo) []config.ProviderInfo { + var out []config.ProviderInfo + for _, p := range all { + if len(backendsFor(p)) > 0 { + out = append(out, p) + } + } + return out +} + +func backendsFor(p config.ProviderInfo) []backend { + var out []backend + for _, b := range backends { + if p.Compatibility[b.compatKey] { + out = append(out, b) + } + } + return out +} + +func errorResult(msg string) menu.Result { + return menu.Result{Cmd: func() tea.Msg { + return menu.SimpleDoneMsg{Err: errString(msg)} + }} +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/internal/clients/gemini/gemini_test.go b/internal/clients/gemini/gemini_test.go new file mode 100644 index 0000000..9d1e0ef --- /dev/null +++ b/internal/clients/gemini/gemini_test.go @@ -0,0 +1,78 @@ +package gemini + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +func TestCompatibleProviders(t *testing.T) { + provs := []config.ProviderInfo{ + {ID: "vertex", Compatibility: map[string]bool{"experimental_gemini_cli_vertex_compat": true}}, + {ID: "gemini", Compatibility: map[string]bool{"gemini_generate_content": true}}, + {ID: "openai", Compatibility: map[string]bool{"openai_chat": true}}, + } + got := compatibleProviders(provs) + if len(got) != 2 { + t.Fatalf("compatibleProviders len = %d, want 2", len(got)) + } +} + +func TestBackendsFor_Vertex(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{"experimental_gemini_cli_vertex_compat": true}} + bs := backendsFor(p) + if len(bs) != 1 || bs[0].id != "vertex" { + t.Errorf("backendsFor(vertex) = %+v", bs) + } +} + +func TestBackendsFor_GeminiAPI(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{"gemini_generate_content": true}} + bs := backendsFor(p) + if len(bs) != 1 || bs[0].id != "gemini" { + t.Errorf("backendsFor(gemini) = %+v", bs) + } +} + +func TestBackendsFor_Both(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{ + "experimental_gemini_cli_vertex_compat": true, + "gemini_generate_content": true, + }} + bs := backendsFor(p) + if len(bs) != 2 { + t.Errorf("backendsFor = %+v, want 2", bs) + } +} + +func TestWriteConfig_Vertex(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + home, err := writeConfig("vertex-ai") + if err != nil { + t.Fatalf("writeConfig: %v", err) + } + + data, err := os.ReadFile(filepath.Join(home, ".gemini", "settings.json")) + if err != nil { + t.Fatalf("settings.json: %v", err) + } + var s struct { + Security struct { + Auth struct { + SelectedType string `json:"selectedType"` + } `json:"auth"` + } `json:"security"` + } + if err := json.Unmarshal(data, &s); err != nil { + t.Fatal(err) + } + if s.Security.Auth.SelectedType != "vertex-ai" { + t.Errorf("selectedType = %q, want vertex-ai", s.Security.Auth.SelectedType) + } +} diff --git a/internal/clients/gemini/install.go b/internal/clients/gemini/install.go new file mode 100644 index 0000000..333d080 --- /dev/null +++ b/internal/clients/gemini/install.go @@ -0,0 +1,16 @@ +package gemini + +import ( + "os" + "path/filepath" +) + +func commonBinaryPaths() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return []string{ + filepath.Join(home, ".local", "bin", "gemini"), + } +} diff --git a/internal/clients/launch.go b/internal/clients/launch.go new file mode 100644 index 0000000..5a5f47d --- /dev/null +++ b/internal/clients/launch.go @@ -0,0 +1,66 @@ +package clients + +import ( + "fmt" + "os" + "os/exec" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/menu" +) + +// LaunchSpec describes a foreground client-binary launch. The TUI hands +// control to the child process and regains it when the child exits. +type LaunchSpec struct { + // Binary is the absolute path to the executable. Required. + Binary string + // Args are appended to the command line after Binary. + Args []string + // Env is overlaid on top of os.Environ(). Later keys override earlier + // ones; within Env, order is unspecified (map). + Env map[string]string + // Cleanup runs after the child exits, before the done-msg is emitted. + // Use it to remove temporary config files. + Cleanup func() + // Debug, when true, dumps the resolved Env and Args to stderr before + // exec (matches the `-debug` flag wiring). + Debug bool +} + +// Launch returns a tea.Cmd that runs the given spec via tea.ExecProcess and +// emits menu.ExecDoneMsg when the child exits. If spec.Binary is empty, +// the command returns immediately with an error. +func Launch(spec LaunchSpec) tea.Cmd { + if spec.Binary == "" { + err := fmt.Errorf("binary path is empty") + return func() tea.Msg { return menu.ExecDoneMsg{Err: err} } + } + + envPairs := os.Environ() + for k, v := range spec.Env { + envPairs = append(envPairs, k+"="+v) + } + + if spec.Debug { + fmt.Fprintf(os.Stderr, "\r\n[debug] launching %s\r\n", spec.Binary) + for k, v := range spec.Env { + fmt.Fprintf(os.Stderr, "[debug] %s=%s\r\n", k, v) + } + if len(spec.Args) > 0 { + fmt.Fprintf(os.Stderr, "[debug] args: %v\r\n", spec.Args) + } + } + + cmd := exec.Command(spec.Binary, spec.Args...) + cmd.Env = envPairs + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return tea.ExecProcess(cmd, func(err error) tea.Msg { + if spec.Cleanup != nil { + spec.Cleanup() + } + return menu.ExecDoneMsg{Err: err} + }) +} diff --git a/internal/clients/opencode/install.go b/internal/clients/opencode/install.go new file mode 100644 index 0000000..117cf02 --- /dev/null +++ b/internal/clients/opencode/install.go @@ -0,0 +1,17 @@ +package opencode + +import ( + "os" + "path/filepath" +) + +func commonBinaryPaths() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return []string{ + filepath.Join(home, ".opencode", "bin", "opencode"), + filepath.Join(home, ".local", "bin", "opencode"), + } +} diff --git a/internal/clients/opencode/opencode.go b/internal/clients/opencode/opencode.go new file mode 100644 index 0000000..1538f4b --- /dev/null +++ b/internal/clients/opencode/opencode.go @@ -0,0 +1,242 @@ +// Package opencode is the OpenCode client. Unlike the other clients, +// OpenCode has a single abstract routing flavor: the real protocol (OpenAI +// Responses, OpenAI Chat, Anthropic Messages, Bedrock, Vertex, Gemini) is +// decided at launch time from the chosen provider's compatibility map. The +// Menu flow therefore skips the backend step and goes straight from +// provider selection to model selection. +package opencode + +import ( + "os" + "os/exec" + "path/filepath" + "slices" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/menu" +) + +func init() { + clients.Register(&Client{}) +} + +// Client is the OpenCode client. +type Client struct{} + +const ( + name = "OpenCode" + binaryName = "opencode" +) + +// compatKeys is the set of provider-compatibility flags OpenCode can +// translate into a working config. A provider matches if any one is set. +var compatKeys = []string{ + "openai_responses", + "anthropic_messages", + "openai_chat", + "google_generate_content", + "google_raw_predict", + "bedrock_model_invoke", + "bedrock_converse", + "gemini_generate_content", +} + +// Name implements clients.Client. +func (c *Client) Name() string { return name } + +// BinaryName implements clients.Client. +func (c *Client) BinaryName() string { return binaryName } + +// CommonPaths implements clients.Client. +func (c *Client) CommonPaths() []string { return commonBinaryPaths() } + +// IsInstalled implements clients.Client. +func (c *Client) IsInstalled() bool { + return clients.IsInstalled(binaryName, c.CommonPaths()) +} + +// Install implements clients.Client. +func (c *Client) Install(_ *config.Global) clients.InstallPlan { + return clients.InstallPlan{ + Hint: "curl -fsSL https://opencode.ai/install | bash", + Run: func() (*exec.Cmd, error) { + return exec.Command("/bin/sh", "-c", "curl -fsSL https://opencode.ai/install | bash"), nil + }, + } +} + +// Uninstall implements clients.Client. +func (c *Client) Uninstall() clients.UninstallPlan { + return clients.UninstallPlan{ + Hint: "opencode uninstall --force\nrm -rf ~/.opencode/bin", + Run: func() error { + if err := exec.Command("opencode", "uninstall", "--force").Run(); err != nil { + return err + } + home, err := os.UserHomeDir() + if err != nil { + return err + } + return os.RemoveAll(filepath.Join(home, ".opencode", "bin")) + }, + } +} + +// Menu implements clients.Client. +func (c *Client) Menu(g *config.Global) menu.MenuItem { + return menu.MenuItem{ + Label: name, + Action: func() menu.Result { return c.providerStep(g) }, + } +} + +func (c *Client) providerStep(g *config.Global) menu.Result { + provs := compatibleProviders(g.Providers) + if len(provs) == 0 { + return errorResult("No providers support an OpenCode protocol.") + } + if len(provs) == 1 { + return c.modelStep(g, provs[0]) + } + items := make([]menu.MenuItem, 0, len(provs)) + for _, p := range provs { + items = append(items, menu.MenuItem{ + Label: p.DisplayName(), + Description: p.Description, + Action: func() menu.Result { return c.modelStep(g, p) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a provider for " + name + ":", + Items: items, + }} +} + +func (c *Client) modelStep(g *config.Global, p config.ProviderInfo) menu.Result { + models := fqnModels(p) + if len(models) <= 1 { + var m string + if len(models) == 1 { + m = models[0] + } + return c.launch(g, p, m) + } + items := make([]menu.MenuItem, 0, len(models)) + for _, m := range models { + items = append(items, menu.MenuItem{ + Label: m, + Action: func() menu.Result { return c.launch(g, p, m) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a default model for " + name + " via " + p.DisplayName() + ":", + Items: items, + }} +} + +func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) menu.Result { + bin := clients.FindBinary(binaryName, c.CommonPaths()) + if bin == "" { + bin = binaryName + } + configPath, cleanup, err := writeProviderConfig(g.ApertureHost, p) + if err != nil { + return errorResult("Failed to write OpenCode config: " + err.Error()) + } + + env := map[string]string{ + "OPENCODE_CONFIG": configPath, + } + // Bedrock SDK requires at least placeholder AWS credentials and region. + if p.Compatibility["bedrock_model_invoke"] || p.Compatibility["bedrock_converse"] { + env["AWS_ACCESS_KEY_ID"] = "not-needed" + env["AWS_SECRET_ACCESS_KEY"] = "not-needed" + env["AWS_REGION"] = "us-east-1" + } + + // OpenCode has no documented yolo flag today; keep Args empty. Model is + // conveyed via the provider config written above, not a CLI arg. + _ = g.RecordLaunch(config.LaunchState{ + LastClientName: name, + LastBackendType: "openai", // historical; OpenCode's abstract backend + LastProviderID: p.ID, + LastModel: model, + }) + + cmd := clients.Launch(clients.LaunchSpec{ + Binary: bin, + Env: env, + Cleanup: cleanup, + Debug: g.Debug, + }) + return menu.Result{Cmd: cmd, PopOnDone: true} +} + +// Replay implements clients.Client. +func (c *Client) Replay(g *config.Global) tea.Cmd { + if g.LastLaunch.LastClientName != name || !c.IsInstalled() { + return nil + } + prov, ok := g.Provider(g.LastLaunch.LastProviderID) + if !ok { + return nil + } + if !providerMatches(prov) { + return nil + } + model := g.LastLaunch.LastModel + if model != "" && !slices.Contains(fqnModels(prov), model) { + return nil + } + res := c.launch(g, prov, model) + return res.Cmd +} + +// QuickSelectLabel implements clients.Client. +func (c *Client) QuickSelectLabel(g *config.Global) string { + prov, _ := g.Provider(g.LastLaunch.LastProviderID) + label := name + " via " + prov.DisplayName() + if g.LastLaunch.LastModel != "" { + label += " - " + g.LastLaunch.LastModel + } + return label +} + +func compatibleProviders(all []config.ProviderInfo) []config.ProviderInfo { + var out []config.ProviderInfo + for _, p := range all { + if providerMatches(p) { + out = append(out, p) + } + } + return out +} + +func providerMatches(p config.ProviderInfo) bool { + for _, k := range compatKeys { + if p.Compatibility[k] { + return true + } + } + return false +} + +func fqnModels(p config.ProviderInfo) []string { + out := make([]string, len(p.Models)) + for i, m := range p.Models { + out[i] = p.ID + "/" + m + } + return out +} + +func errorResult(msg string) menu.Result { + return menu.Result{Cmd: func() tea.Msg { + return menu.SimpleDoneMsg{Err: errString(msg)} + }} +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/internal/clients/opencode/opencode_test.go b/internal/clients/opencode/opencode_test.go new file mode 100644 index 0000000..d078b4f --- /dev/null +++ b/internal/clients/opencode/opencode_test.go @@ -0,0 +1,205 @@ +package opencode + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +const testHost = "http://ai.example.com" + +func TestCompatibleProviders(t *testing.T) { + provs := []config.ProviderInfo{ + {ID: "anthropic", Compatibility: map[string]bool{"anthropic_messages": true}}, + {ID: "openai", Compatibility: map[string]bool{"openai_chat": true}}, + {ID: "bedrock", Compatibility: map[string]bool{"bedrock_converse": true}}, + {ID: "none", Compatibility: map[string]bool{"something_else": true}}, + } + got := compatibleProviders(provs) + if len(got) != 3 { + t.Errorf("compatibleProviders len = %d, want 3: %+v", len(got), got) + } +} + +func TestPickSDK(t *testing.T) { + cases := []struct { + name string + compat map[string]bool + wantNPM string + }{ + {"responses", map[string]bool{"openai_responses": true}, "@ai-sdk/openai"}, + {"anthropic", map[string]bool{"anthropic_messages": true}, "@ai-sdk/anthropic"}, + {"chat_only", map[string]bool{"openai_chat": true}, "@ai-sdk/openai-compatible"}, + {"vertex", map[string]bool{"google_generate_content": true}, "@ai-sdk/google-vertex"}, + {"bedrock", map[string]bool{"bedrock_converse": true}, "@ai-sdk/amazon-bedrock"}, + {"gemini", map[string]bool{"gemini_generate_content": true}, "@ai-sdk/google"}, + {"none", map[string]bool{"unknown": true}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + npm, _ := pickSDK(tc.compat, testHost) + if npm != tc.wantNPM { + t.Errorf("npm = %q, want %q", npm, tc.wantNPM) + } + }) + } +} + +func TestPickSDK_ResponsesBeatsChat(t *testing.T) { + npm, _ := pickSDK(map[string]bool{ + "openai_chat": true, + "openai_responses": true, + }, testHost) + if npm != "@ai-sdk/openai" { + t.Errorf("npm = %q, want @ai-sdk/openai (responses should win)", npm) + } +} + +func TestWriteProviderConfig(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + tests := []struct { + name string + provider config.ProviderInfo + wantNPM string + wantOptions map[string]string + }{ + { + name: "anthropic_messages", + provider: config.ProviderInfo{ + ID: "anthropic", Name: "Anthropic", + Models: []string{"claude-sonnet-4-5", "claude-haiku-4-5"}, + Compatibility: map[string]bool{"anthropic_messages": true}, + }, + wantNPM: "@ai-sdk/anthropic", + wantOptions: map[string]string{ + "baseURL": testHost + "/v1", + "apiKey": "not-required", + }, + }, + { + name: "bedrock_converse", + provider: config.ProviderInfo{ + ID: "bedrock", Name: "AWS Bedrock", + Models: []string{"us.anthropic.claude-opus-4-7"}, + Compatibility: map[string]bool{"bedrock_converse": true}, + }, + wantNPM: "@ai-sdk/amazon-bedrock", + wantOptions: map[string]string{ + "region": "us-east-1", + "endpoint": testHost + "/bedrock", + }, + }, + { + name: "google_generate_content", + provider: config.ProviderInfo{ + ID: "vertex", Name: "Vertex", + Models: []string{"gemini-2.5-pro"}, + Compatibility: map[string]bool{ + "google_generate_content": true, + "google_raw_predict": true, + }, + }, + wantNPM: "@ai-sdk/google-vertex", + wantOptions: map[string]string{ + "apiKey": "not-required", + "baseURL": testHost + "/v1/projects/_aperture_auto_vertex_project_id_/locations/_aperture_auto_vertex_region_/publishers/google", + }, + }, + { + name: "openai_responses", + provider: config.ProviderInfo{ + ID: "openai", Name: "OpenAI", + Models: []string{"gpt-5"}, + Compatibility: map[string]bool{ + "openai_chat": true, + "openai_responses": true, + }, + }, + wantNPM: "@ai-sdk/openai", + wantOptions: map[string]string{ + "baseURL": testHost + "/v1", + "apiKey": "not-required", + }, + }, + { + name: "openai_chat_only", + provider: config.ProviderInfo{ + ID: "openrouter", Name: "OpenRouter", + Models: []string{"qwen/qwen3-235b-a22b-2507"}, + Compatibility: map[string]bool{"openai_chat": true}, + }, + wantNPM: "@ai-sdk/openai-compatible", + wantOptions: map[string]string{ + "baseURL": testHost + "/v1", + "apiKey": "not-required", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath, cleanup, err := writeProviderConfig(testHost, tt.provider) + if err != nil { + t.Fatalf("writeProviderConfig: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("config file not readable: %v", err) + } + var cfg struct { + Provider map[string]struct { + NPM string `json:"npm"` + Name string `json:"name"` + Options map[string]string `json:"options"` + Models map[string]map[string]string `json:"models"` + Whitelist []string `json:"whitelist"` + } `json:"provider"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("json: %v", err) + } + prov, ok := cfg.Provider[tt.provider.ID] + if !ok { + t.Fatalf("provider %q missing from config", tt.provider.ID) + } + if prov.NPM != tt.wantNPM { + t.Errorf("npm = %q, want %q", prov.NPM, tt.wantNPM) + } + wantName := "Aperture (" + tt.provider.ID + ")" + if prov.Name != wantName { + t.Errorf("name = %q, want %q", prov.Name, wantName) + } + for k, want := range tt.wantOptions { + if got := prov.Options[k]; got != want { + t.Errorf("options[%q] = %q, want %q", k, got, want) + } + } + if len(prov.Models) != len(tt.provider.Models) { + t.Errorf("models len = %d, want %d", len(prov.Models), len(tt.provider.Models)) + } + for _, m := range tt.provider.Models { + fqn := tt.provider.ID + "/" + m + entry, ok := prov.Models[fqn] + if !ok { + t.Errorf("model %q missing from config", fqn) + continue + } + if entry["id"] != m { + t.Errorf("model %q id = %q, want %q", fqn, entry["id"], m) + } + } + + cleanup() + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Errorf("config file still exists after cleanup") + } + }) + } +} diff --git a/internal/profiles/opencode.go b/internal/clients/opencode/sdk.go similarity index 50% rename from internal/profiles/opencode.go rename to internal/clients/opencode/sdk.go index 107e480..6b17fd8 100644 --- a/internal/profiles/opencode.go +++ b/internal/clients/opencode/sdk.go @@ -1,96 +1,12 @@ -package profiles +package opencode import ( "encoding/json" "os" - "os/exec" "path/filepath" -) - -// OpenCodeProfile implements Profile for the `opencode` CLI tool. -type OpenCodeProfile struct { -} - -func (o *OpenCodeProfile) Name() string { return "OpenCode" } - -func (o *OpenCodeProfile) BinaryName() string { return "opencode" } -func (o *OpenCodeProfile) CommonPaths() []string { - home, err := os.UserHomeDir() - if err != nil { - return nil - } - return []string{ - filepath.Join(home, ".opencode", "bin", "opencode"), - filepath.Join(home, ".local", "bin", "opencode"), - } -} - -func (o *OpenCodeProfile) InstallHint() string { - return "curl -fsSL https://opencode.ai/install | bash" -} - -func (o *OpenCodeProfile) UninstallHint() string { - return "opencode uninstall --force\nrm -rf ~/.opencode/bin" -} - -func (o *OpenCodeProfile) Uninstall() func() error { - return func() error { - if err := exec.Command("opencode", "uninstall", "--force").Run(); err != nil { - return err - } - home, err := os.UserHomeDir() - if err != nil { - return err - } - return os.RemoveAll(filepath.Join(home, ".opencode", "bin")) - } -} - -// openCodeBackend is the single abstract backend OpenCode advertises. The -// real routing is decided per-provider from its compatibility map. -var openCodeBackend = Backend{Type: BackendOpenAI, DisplayName: "OpenCode"} - -func (o *OpenCodeProfile) SupportedBackends() []Backend { - return []Backend{openCodeBackend} -} - -// RequiredCompat accepts any provider that speaks one of the protocols we can -// translate into an OpenCode config. -func (o *OpenCodeProfile) RequiredCompat(Backend) []string { - return []string{ - "openai_responses", - "anthropic_messages", - "openai_chat", - "google_generate_content", - "google_raw_predict", - "bedrock_model_invoke", - "bedrock_converse", - "gemini_generate_content", - } -} - -// Env returns backend-agnostic env vars. Provider-specific env vars (AWS, -// Google Vertex magic strings) are set via ProviderEnv. -func (o *OpenCodeProfile) Env(string, Backend) (map[string]string, error) { - return map[string]string{}, nil -} - -// ProviderEnv sets env vars that depend on the chosen provider's protocol. -func (o *OpenCodeProfile) ProviderEnv(_ Backend, providers []ProviderInfo) map[string]string { - if len(providers) == 0 { - return nil - } - p := providers[0] - if p.Compatibility["bedrock_model_invoke"] || p.Compatibility["bedrock_converse"] { - return map[string]string{ - "AWS_ACCESS_KEY_ID": "not-needed", - "AWS_SECRET_ACCESS_KEY": "not-needed", - "AWS_REGION": "us-east-1", - } - } - return nil -} + "github.com/tailscale/aperture-cli/internal/config" +) type opencodeConfig struct { Schema string `json:"$schema,omitempty"` @@ -113,10 +29,10 @@ type opencodeModelEntry struct { Name string `json:"name,omitempty"` } -// pickOpenCodeSDK chooses the AI SDK npm package and baseline options for a -// provider based on its compatibility map. Order matters: when a provider -// supports multiple protocols, the first match wins. -func pickOpenCodeSDK(compat map[string]bool, apertureHost string) (npm string, options map[string]string) { +// pickSDK chooses the AI SDK npm package and baseline options for a provider +// based on its compatibility map. Order matters: when a provider supports +// multiple protocols, the first match wins. +func pickSDK(compat map[string]bool, apertureHost string) (npm string, options map[string]string) { switch { case compat["openai_responses"]: return "@ai-sdk/openai", map[string]string{ @@ -156,8 +72,12 @@ func pickOpenCodeSDK(compat map[string]bool, apertureHost string) (npm string, o return "", nil } -func (o *OpenCodeProfile) WriteProviderConfig(apertureHost string, _ Backend, p ProviderInfo) (string, string, func(), error) { - npm, options := pickOpenCodeSDK(p.Compatibility, apertureHost) +// writeProviderConfig writes the per-launch OpenCode config under +// ~/.opencode/tmp_aperture_config.json and returns the path plus a cleanup +// function that removes the file. The config defines one provider (the +// chosen one) mapped to the SDK picked from its compatibility map. +func writeProviderConfig(apertureHost string, p config.ProviderInfo) (string, func(), error) { + npm, options := pickSDK(p.Compatibility, apertureHost) models := make(map[string]opencodeModelEntry, len(p.Models)) whitelist := make([]string, 0, len(p.Models)) @@ -182,20 +102,20 @@ func (o *OpenCodeProfile) WriteProviderConfig(apertureHost string, _ Backend, p data, err := json.Marshal(cfg) if err != nil { - return "", "", nil, err + return "", nil, err } home, err := os.UserHomeDir() if err != nil { - return "", "", nil, err + return "", nil, err } configDir := filepath.Join(home, ".opencode") if err := os.MkdirAll(configDir, 0o700); err != nil { - return "", "", nil, err + return "", nil, err } path := filepath.Join(configDir, "tmp_aperture_config.json") if err := os.WriteFile(path, data, 0o600); err != nil { - return "", "", nil, err + return "", nil, err } - return "OPENCODE_CONFIG", path, func() { os.Remove(path) }, nil + return path, func() { os.Remove(path) }, nil } diff --git a/internal/clients/registry.go b/internal/clients/registry.go new file mode 100644 index 0000000..893017d --- /dev/null +++ b/internal/clients/registry.go @@ -0,0 +1,90 @@ +package clients + +import ( + "os/exec" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/menu" +) + +// Client is one AI coding agent that the launcher can install and launch. +// Each client lives in its own sub-package and is wholly responsible for +// its own provider/backend/model flow, env generation, config writing, +// install and uninstall — all of which are expressed through the MenuItem +// returned from Menu() plus the InstallPlan / UninstallPlan. +type Client interface { + // Name is the user-visible display name (e.g. "Claude Code"). + Name() string + + // BinaryName is the executable name checked against $PATH. Empty for + // clients that are not a CLI binary (e.g. desktop apps). + BinaryName() string + + // CommonPaths returns absolute paths where the binary may live outside + // of PATH (e.g. "~/.local/bin/claude"). Used as a fallback by + // FindBinary. + CommonPaths() []string + + // IsInstalled reports whether the client is available locally. The + // default implementation is clients.IsInstalled(BinaryName, CommonPaths). + IsInstalled() bool + + // Install describes how to install the client. May read g for + // host-dependent setup (e.g. writing platform config before download). + Install(g *config.Global) InstallPlan + + // Uninstall describes how to uninstall the client. + Uninstall() UninstallPlan + + // Menu returns the root menu item shown in the client picker. Its + // Action kicks off the client's own sub-menu flow (provider → backend → + // model → launch). + Menu(g *config.Global) menu.MenuItem + + // Replay attempts to re-launch the client using the last-used selection + // stored in g.LastLaunch. Returns nil if the state is stale (binary + // missing, provider gone from g.Providers, model no longer listed). + Replay(g *config.Global) tea.Cmd + + // QuickSelectLabel is the display text for the [0] quick-select row + // when Replay would succeed. + QuickSelectLabel(g *config.Global) string +} + +// InstallPlan describes how to install a client. +type InstallPlan struct { + // Hint is shown to the user before confirming; e.g. + // "curl -fsSL https://claude.ai/install.sh | bash". + Hint string + // Run returns the command to execute on confirmation. If nil, the install + // is manual-only: the TUI shows Hint and does nothing. + Run func() (*exec.Cmd, error) +} + +// UninstallPlan describes how to uninstall a client. +type UninstallPlan struct { + // Hint is shown to the user before confirming. + Hint string + // Run performs the uninstall. If nil, uninstall is disabled. + Run func() error +} + +// registered holds the set of clients, populated by init() in each sub-package +// via Register. +var registered []Client + +// Register adds a client to the registry. Intended to be called from a +// sub-package's init(). Order of registration determines display order. +func Register(c Client) { + registered = append(registered, c) +} + +// All returns every registered client. The g argument is accepted so callers +// always have the global state at hand; it is not currently used for +// filtering since every client decides its own menu behavior when invoked. +func All(g *config.Global) []Client { + out := make([]Client, len(registered)) + copy(out, registered) + return out +} diff --git a/internal/config/client_config.go b/internal/config/client_config.go new file mode 100644 index 0000000..5895560 --- /dev/null +++ b/internal/config/client_config.go @@ -0,0 +1,75 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// ClientConfigDir returns the directory where a client may store its own +// isolated state. The directory is created if it does not exist. Typical +// usage: clients that manage their own on-disk home (e.g. Codex's CODEX_HOME, +// Gemini's GEMINI_CLI_HOME) pass the returned path to the agent binary. +func ClientConfigDir(name string) (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + p := filepath.Join(dir, "aperture", "clients", name) + if err := os.MkdirAll(p, 0o700); err != nil { + return "", err + } + return p, nil +} + +// TypedStore is a JSON file holding a single value of type T. Each call to +// Load/Save round-trips through disk; the store holds no in-memory cache. +type TypedStore[T any] struct { + path string +} + +// ClientConfig returns a typed JSON store at +// /aperture/clients/.json. The file is created on first +// Save. Load returns a zero T if the file does not exist. +func ClientConfig[T any](name string) (*TypedStore[T], error) { + dir, err := os.UserConfigDir() + if err != nil { + return nil, err + } + return &TypedStore[T]{ + path: filepath.Join(dir, "aperture", "clients", name+".json"), + }, nil +} + +// Load reads the stored value. Returns a zero T if the file is missing or +// unreadable. +func (s *TypedStore[T]) Load() (T, error) { + var zero T + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return zero, nil + } + return zero, err + } + var v T + if err := json.Unmarshal(data, &v); err != nil { + return zero, err + } + return v, nil +} + +// Save persists the given value. Creates the parent directory if needed. +func (s *TypedStore[T]) Save(v T) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0o600) +} + +// Path returns the absolute path to the config file. +func (s *TypedStore[T]) Path() string { return s.path } diff --git a/internal/config/global.go b/internal/config/global.go new file mode 100644 index 0000000..17a3b98 --- /dev/null +++ b/internal/config/global.go @@ -0,0 +1,114 @@ +package config + +// Global is the live mutable app-level state threaded through the TUI and +// every client package. It holds the current Aperture endpoint, the user's +// persisted settings, the last-launch record, and the provider list fetched +// from the active endpoint. Mutator methods persist to disk on success. +type Global struct { + // ApertureHost is the currently active Aperture endpoint URL. + ApertureHost string + + // Settings is the persisted user configuration (endpoint list, YOLO mode). + Settings Settings + + // LastLaunch is the persisted record of the last successful client launch. + LastLaunch LaunchState + + // Providers is the list returned from the active endpoint's /api/providers. + // Populated by the TUI's preflight after a successful check. + Providers []ProviderInfo + + // Debug enables verbose stderr dumps of env/args before each launch. + // Not persisted; set from the --debug flag. + Debug bool +} + +// Load reads Settings and LaunchState from disk and returns a populated +// Global. The active ApertureHost is the first endpoint if any are configured, +// otherwise DefaultLocation. Providers is left empty for the TUI to populate +// after its preflight. +func Load() (*Global, error) { + s, err := LoadSettings() + if err != nil { + return nil, err + } + ls, err := LoadState() + if err != nil { + return nil, err + } + host := DefaultLocation + if len(s.Endpoints) > 0 { + host = s.Endpoints[0].URL + } + return &Global{ + ApertureHost: host, + Settings: s, + LastLaunch: ls, + }, nil +} + +// SetYolo toggles YOLO mode and persists the new setting. +func (g *Global) SetYolo(on bool) error { + g.Settings.YoloMode = on + return SaveSettings(g.Settings) +} + +// SetApertureHost rotates the given URL to the front of the endpoint list +// (adding it if missing), updates ApertureHost, and persists. +func (g *Global) SetApertureHost(url string) error { + g.ApertureHost = url + eps := []Endpoint{{URL: url}} + for _, ep := range g.Settings.Endpoints { + if ep.URL != url { + eps = append(eps, ep) + } + } + g.Settings.Endpoints = eps + return SaveSettings(g.Settings) +} + +// UpsertEndpoint appends the URL to the endpoint list if not already present, +// without changing which endpoint is active, and persists. +func (g *Global) UpsertEndpoint(url string) error { + for _, ep := range g.Settings.Endpoints { + if ep.URL == url { + return nil + } + } + g.Settings.Endpoints = append(g.Settings.Endpoints, Endpoint{URL: url}) + return SaveSettings(g.Settings) +} + +// RemoveEndpoint deletes the endpoint at idx and persists. The active endpoint +// is kept pointing at index 0 after removal; callers are responsible for +// re-running preflight if the active endpoint changed. +func (g *Global) RemoveEndpoint(idx int) error { + if idx < 0 || idx >= len(g.Settings.Endpoints) { + return nil + } + eps := make([]Endpoint, 0, len(g.Settings.Endpoints)-1) + eps = append(eps, g.Settings.Endpoints[:idx]...) + eps = append(eps, g.Settings.Endpoints[idx+1:]...) + g.Settings.Endpoints = eps + if len(eps) > 0 { + g.ApertureHost = eps[0].URL + } + return SaveSettings(g.Settings) +} + +// RecordLaunch stores the launch record to disk and updates the in-memory copy. +func (g *Global) RecordLaunch(s LaunchState) error { + g.LastLaunch = s + return SaveState(s) +} + +// Provider returns the ProviderInfo for id, or a zero value and false if no +// such provider is in g.Providers. +func (g *Global) Provider(id string) (ProviderInfo, bool) { + for _, p := range g.Providers { + if p.ID == id { + return p, true + } + } + return ProviderInfo{}, false +} diff --git a/internal/config/providers.go b/internal/config/providers.go new file mode 100644 index 0000000..8b8585c --- /dev/null +++ b/internal/config/providers.go @@ -0,0 +1,18 @@ +package config + +// ProviderInfo mirrors the JSON response from GET /api/providers. +type ProviderInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Models []string `json:"models"` + Compatibility map[string]bool `json:"compatibility"` +} + +// DisplayName returns the provider's Name, falling back to ID if Name is empty. +func (p ProviderInfo) DisplayName() string { + if p.Name != "" { + return p.Name + } + return p.ID +} diff --git a/internal/profiles/settings.go b/internal/config/settings.go similarity index 69% rename from internal/profiles/settings.go rename to internal/config/settings.go index fe786d8..9b6700d 100644 --- a/internal/profiles/settings.go +++ b/internal/config/settings.go @@ -1,4 +1,8 @@ -package profiles +// Package config holds the launcher's app-level persistent state: the list +// of Aperture endpoints the user has configured, the active endpoint, the +// YOLO-mode flag, and the record of the last-used client launch. Clients +// also reach through this package for isolated per-client JSON storage. +package config import ( "encoding/json" @@ -6,7 +10,9 @@ import ( "path/filepath" ) -const defaultLocation = "http://ai" +// DefaultLocation is the fallback Aperture endpoint URL used when the user +// has no saved settings. +const DefaultLocation = "http://ai" // Endpoint holds the URL and per-endpoint configuration for an Aperture proxy. type Endpoint struct { @@ -19,9 +25,9 @@ type Settings struct { // The first entry is used as the active endpoint on startup. Endpoints []Endpoint `json:"endpoints,omitempty"` - // YoloMode appends each profile's skip-permissions args (e.g. - // --dangerously-skip-permissions for claude, -yolo for gemini) when - // launching an agent. + // YoloMode appends each client's skip-permissions args (e.g. + // --dangerously-skip-permissions for Claude Code, --yolo for Gemini) + // when launching an agent. YoloMode bool `json:"yoloMode,omitempty"` } @@ -50,7 +56,7 @@ func LoadSettings() (Settings, error) { return defaultSettings(), nil } if len(s.Endpoints) == 0 { - s.Endpoints = []Endpoint{{URL: defaultLocation}} + s.Endpoints = []Endpoint{{URL: DefaultLocation}} } return s, nil } @@ -73,6 +79,6 @@ func SaveSettings(s Settings) error { func defaultSettings() Settings { return Settings{ - Endpoints: []Endpoint{{URL: defaultLocation}}, + Endpoints: []Endpoint{{URL: DefaultLocation}}, } } diff --git a/internal/config/state.go b/internal/config/state.go new file mode 100644 index 0000000..ae526e6 --- /dev/null +++ b/internal/config/state.go @@ -0,0 +1,84 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// LaunchState records the last-used client/backend/provider/model so the TUI +// can offer a one-key quick re-launch on startup. +type LaunchState struct { + LastClientName string `json:"lastClientName,omitempty"` + LastBackendType string `json:"lastBackendType,omitempty"` + LastProviderID string `json:"lastProviderId,omitempty"` + LastModel string `json:"lastModel,omitempty"` +} + +// statePath returns the path to the launcher state JSON file. +func statePath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "aperture", "launcher.json"), nil +} + +// LoadState reads the persisted launcher state. Errors are silently ignored +// and a zero LaunchState is returned. +func LoadState() (LaunchState, error) { + path, err := statePath() + if err != nil { + return LaunchState{}, nil + } + data, err := os.ReadFile(path) + if err != nil { + return LaunchState{}, nil + } + var s LaunchState + if err := json.Unmarshal(data, &s); err != nil { + // Fall back to the legacy schema used by earlier versions of the + // launcher, which named the field lastProfileName. + var legacy struct { + LastProfileName string `json:"lastProfileName,omitempty"` + LastBackendType string `json:"lastBackendType,omitempty"` + LastProviderID string `json:"lastProviderId,omitempty"` + LastModel string `json:"lastModel,omitempty"` + } + if err := json.Unmarshal(data, &legacy); err != nil { + return LaunchState{}, nil + } + s = LaunchState{ + LastClientName: legacy.LastProfileName, + LastBackendType: legacy.LastBackendType, + LastProviderID: legacy.LastProviderID, + LastModel: legacy.LastModel, + } + } + // Accept old-format files that only have lastProfileName set. + if s.LastClientName == "" { + var legacy struct { + LastProfileName string `json:"lastProfileName,omitempty"` + } + if err := json.Unmarshal(data, &legacy); err == nil && legacy.LastProfileName != "" { + s.LastClientName = legacy.LastProfileName + } + } + return s, nil +} + +// SaveState persists the launcher state to disk. +func SaveState(s LaunchState) error { + path, err := statePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.Marshal(s) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} diff --git a/internal/config/state_test.go b/internal/config/state_test.go new file mode 100644 index 0000000..705a51b --- /dev/null +++ b/internal/config/state_test.go @@ -0,0 +1,163 @@ +package config_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +func TestLaunchState_RoundTrip(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg == "" { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + } + + want := config.LaunchState{ + LastClientName: "Claude Code", + LastBackendType: "bedrock", + LastProviderID: "anthropic-via-aperture", + LastModel: "anthropic-via-aperture/claude-sonnet", + } + if err := config.SaveState(want); err != nil { + t.Fatalf("SaveState: %v", err) + } + + got, err := config.LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + + if got != want { + t.Errorf("LoadState = %+v, want %+v", got, want) + } +} + +func TestLaunchState_LegacyMigration(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + // Seed a launcher.json in the old shape that used lastProfileName. + dir := filepath.Join(tmp, ".config", "aperture") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + legacy := map[string]string{ + "lastProfileName": "Claude Code", + "lastBackendType": "anthropic", + "lastProviderId": "anthropic-via-aperture", + "lastModel": "anthropic-via-aperture/claude-sonnet", + } + data, _ := json.Marshal(legacy) + if err := os.WriteFile(filepath.Join(dir, "launcher.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + got, err := config.LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if got.LastClientName != "Claude Code" { + t.Errorf("LastClientName = %q, want %q", got.LastClientName, "Claude Code") + } + if got.LastProviderID != "anthropic-via-aperture" { + t.Errorf("LastProviderID = %q", got.LastProviderID) + } +} + +func TestSettings_RoundTrip(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + want := config.Settings{ + Endpoints: []config.Endpoint{ + {URL: "http://ai"}, + {URL: "http://aperture.example.com"}, + }, + YoloMode: true, + } + if err := config.SaveSettings(want); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + + got, err := config.LoadSettings() + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + if len(got.Endpoints) != 2 || got.Endpoints[0].URL != "http://ai" { + t.Errorf("endpoints = %+v", got.Endpoints) + } + if !got.YoloMode { + t.Error("YoloMode = false, want true") + } +} + +func TestGlobal_SetApertureHost_RotatesToFront(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + g := &config.Global{ + Settings: config.Settings{ + Endpoints: []config.Endpoint{ + {URL: "http://a"}, + {URL: "http://b"}, + {URL: "http://c"}, + }, + }, + } + if err := g.SetApertureHost("http://b"); err != nil { + t.Fatal(err) + } + if g.ApertureHost != "http://b" { + t.Errorf("ApertureHost = %q", g.ApertureHost) + } + if g.Settings.Endpoints[0].URL != "http://b" { + t.Errorf("front endpoint = %q, want http://b", g.Settings.Endpoints[0].URL) + } + if len(g.Settings.Endpoints) != 3 { + t.Errorf("endpoints len = %d, want 3", len(g.Settings.Endpoints)) + } +} + +func TestClientConfig_TypedStore(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + type myCfg struct { + Foo string `json:"foo"` + N int `json:"n"` + } + + store, err := config.ClientConfig[myCfg]("test-client") + if err != nil { + t.Fatal(err) + } + + got, err := store.Load() + if err != nil { + t.Fatalf("Load on missing file: %v", err) + } + if got != (myCfg{}) { + t.Errorf("Load on missing file = %+v, want zero", got) + } + + want := myCfg{Foo: "bar", N: 42} + if err := store.Save(want); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err = store.Load() + if err != nil { + t.Fatalf("Load after Save: %v", err) + } + if got != want { + t.Errorf("Load = %+v, want %+v", got, want) + } +} diff --git a/internal/menu/menu.go b/internal/menu/menu.go new file mode 100644 index 0000000..b791b6c --- /dev/null +++ b/internal/menu/menu.go @@ -0,0 +1,74 @@ +// Package menu defines the descriptors the TUI uses to render a generic, +// navigable menu stack. Every client package builds its install/launch flow +// by returning nested Menu values from its action closures; the TUI takes +// care of cursor movement, digit shortcuts, Esc-to-pop, and dispatching +// tea.Cmds. +package menu + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// DigitZero pins a MenuItem to the numeric shortcut "0". It is a distinct +// sentinel because zero is the natural zero value for the Digit field, +// which has "use default" semantics. +const DigitZero = -10 + +// Kind identifies a MenuItem's rendering style. +type Kind int + +const ( + // KindDefault is a normal selectable row. + KindDefault Kind = iota + // KindInput is a single-line text input row; the TUI captures keystrokes + // into MenuItem.Label and calls Action on Enter. + KindInput +) + +// MenuItem is one selectable row in a Menu. +type MenuItem struct { + Label string + Description string + Kind Kind + Disabled bool + // Hidden items are skipped by the renderer but kept in the slice so + // numeric shortcuts remain stable. + Hidden bool + // Digit, when set to a non-negative value, renders as the leading "[N]" + // prefix and makes the item selectable via that keystroke. A zero + // value means "use the default": the item's 1-based position in the + // menu's Items slice. Set Digit to DigitZero to pin the item to [0]. + Digit int + // Shortcut is an alternative single-character key that activates this + // item (e.g. "s" for Settings, "a" for Add endpoint). Empty disables. + Shortcut string + // Action runs when the item is selected. + Action func() Result +} + +// Menu is a list of selectable items plus optional title and footer hint. +type Menu struct { + Title string + Items []MenuItem + Hint string + // OnBack, when non-nil, overrides the default "pop stack one level" + // behavior on Esc. Returning a nil tea.Cmd simply stays on this menu. + OnBack func() tea.Cmd +} + +// Result is what an Action returns. Exactly one field is populated. +type Result struct { + // Next pushes a submenu onto the stack. + Next *Menu + // Replace swaps the top of the stack in place. + Replace *Menu + // Cmd dispatches a tea.Cmd. The engine pops the stack when the command's + // done-msg arrives (typically via ExecProcess). Use PopOnDone=false to + // suppress that. + Cmd tea.Cmd + PopOnDone bool + // Pop goes back one level. + Pop bool + // Quit terminates the program. + Quit bool +} diff --git a/internal/menu/msgs.go b/internal/menu/msgs.go new file mode 100644 index 0000000..d547f2f --- /dev/null +++ b/internal/menu/msgs.go @@ -0,0 +1,21 @@ +package menu + +// ExecDoneMsg is emitted when a foreground client process exits (launched +// via tea.ExecProcess from inside a client's Action). The TUI's menu engine +// handles this by clearing the stack and re-running preflight. +type ExecDoneMsg struct{ Err error } + +// InstallDoneMsg is emitted when an install command finishes. The TUI's +// menu engine handles this by rebuilding the root menu (re-scanning which +// clients are installed). +type InstallDoneMsg struct{ Err error } + +// LaunchDoneMsg is emitted when a GUI launch (desktop app) returns control +// immediately. Unlike ExecDoneMsg, the TUI does not re-run preflight — +// launching a desktop app does not invalidate anything. +type LaunchDoneMsg struct{ Err error } + +// SimpleDoneMsg is a generic "a tea.Cmd finished" marker that the engine +// uses to pop the stack one level. Suitable for uninstall-style actions +// that complete synchronously without touching the agent binary layout. +type SimpleDoneMsg struct{ Err error } diff --git a/internal/profiles/adapter.go b/internal/profiles/adapter.go new file mode 100644 index 0000000..9a29054 --- /dev/null +++ b/internal/profiles/adapter.go @@ -0,0 +1,97 @@ +package profiles + +import ( + "os/exec" + "runtime" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/menu" +) + +// RegisterIfSupported registers the Claude Desktop client on platforms that +// support it (darwin, windows). Call from the cmd entrypoint after importing +// the other client packages, to keep platform gating in one place. +func RegisterIfSupported() { + if runtime.GOOS != "darwin" && runtime.GOOS != "windows" { + return + } + clients.Register(&desktopClient{}) +} + +// desktopClient adapts ClaudeDesktopProfile to the clients.Client contract. +// Unlike the CLI clients, Claude Desktop has no provider step: it always +// routes to Anthropic via the aperture gateway. The adapter's Menu is a +// single-item "launch" action; Install writes platform gateway config and +// returns a download command. +type desktopClient struct{} + +const ( + desktopName = "Claude Cowork" + desktopBackendID = string(BackendAnthropic) +) + +func (c *desktopClient) Name() string { return desktopName } +func (c *desktopClient) BinaryName() string { return platformBinaryName() } +func (c *desktopClient) CommonPaths() []string { return platformCommonPaths() } + +func (c *desktopClient) IsInstalled() bool { + return clients.IsInstalled(c.BinaryName(), c.CommonPaths()) +} + +func (c *desktopClient) Install(g *config.Global) clients.InstallPlan { + return clients.InstallPlan{ + Hint: platformInstallHint(), + Run: func() (*exec.Cmd, error) { + if err := platformConfigure(GatewayURL(g.ApertureHost)); err != nil { + return nil, err + } + return platformInstallCmd(), nil + }, + } +} + +func (c *desktopClient) Uninstall() clients.UninstallPlan { + // Desktop uninstall is user-driven via the OS — no scripted path today. + return clients.UninstallPlan{ + Hint: "Uninstall Claude from your operating system's app manager.", + } +} + +func (c *desktopClient) Menu(g *config.Global) menu.MenuItem { + return menu.MenuItem{ + Label: desktopName, + Action: func() menu.Result { return c.launch(g) }, + } +} + +func (c *desktopClient) launch(g *config.Global) menu.Result { + _ = g.RecordLaunch(config.LaunchState{ + LastClientName: desktopName, + LastBackendType: desktopBackendID, + }) + host := g.ApertureHost + cmd := func() tea.Msg { + wantURL := GatewayURL(host) + if currentURL := platformReadGatewayURL(); currentURL != wantURL { + if err := platformConfigure(wantURL); err != nil { + return menu.LaunchDoneMsg{Err: err} + } + } + return menu.LaunchDoneMsg{Err: platformLaunch()} + } + return menu.Result{Cmd: cmd, PopOnDone: true} +} + +func (c *desktopClient) Replay(g *config.Global) tea.Cmd { + if g.LastLaunch.LastClientName != desktopName || !c.IsInstalled() { + return nil + } + res := c.launch(g) + return res.Cmd +} + +func (c *desktopClient) QuickSelectLabel(g *config.Global) string { + return desktopName + " (Anthropic via Claude Cowork)" +} diff --git a/internal/profiles/claude_code.go b/internal/profiles/claude_code.go deleted file mode 100644 index cdd24e3..0000000 --- a/internal/profiles/claude_code.go +++ /dev/null @@ -1,238 +0,0 @@ -package profiles - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" -) - -// ClaudeCodeProfile implements Profile for the `claude` CLI tool. -type ClaudeCodeProfile struct { -} - -func (c *ClaudeCodeProfile) Name() string { return "Claude Code" } - -func (c *ClaudeCodeProfile) BinaryName() string { return "claude" } - -func (c *ClaudeCodeProfile) CommonPaths() []string { - home, err := os.UserHomeDir() - if err != nil { - return nil - } - return []string{ - filepath.Join(home, ".local", "bin", "claude"), - } -} - -func (c *ClaudeCodeProfile) SupportedBackends() []Backend { - return []Backend{ - {Type: BackendAnthropic, DisplayName: "Anthropic API"}, - {Type: BackendBedrock, DisplayName: "AWS Bedrock"}, - {Type: BackendVertex, DisplayName: "Google Vertex"}, - {Type: BackendZAI, DisplayName: "z.ai"}, - } -} - -func (c *ClaudeCodeProfile) InstallHint() string { - return "curl -fsSL https://claude.ai/install.sh | bash" -} - -func (c *ClaudeCodeProfile) UninstallHint() string { - return "rm -f ~/.local/bin/claude && rm -rf ~/.local/share/claude" -} - -func (c *ClaudeCodeProfile) Uninstall() func() error { - return func() error { - home, err := os.UserHomeDir() - if err != nil { - return err - } - os.Remove(filepath.Join(home, ".local", "bin", "claude")) - return os.RemoveAll(filepath.Join(home, ".local", "share", "claude")) - } -} - -func (c *ClaudeCodeProfile) YoloArgs() []string { - return []string{"--dangerously-skip-permissions"} -} - -func (c *ClaudeCodeProfile) RequiredCompat(b Backend) []string { - switch b.Type { - case BackendAnthropic: - return []string{"anthropic_messages"} - case BackendBedrock: - return []string{"bedrock_model_invoke"} - case BackendVertex: - return []string{"google_raw_predict"} - case BackendZAI: - return []string{"anthropic_messages"} - default: - return nil - } -} - -func (c *ClaudeCodeProfile) ProviderEnv(b Backend, providers []ProviderInfo) map[string]string { - // ZAI uses fixed model names set in Env; do not override them. - if b.Type == BackendZAI { - return nil - } - - keys := c.RequiredCompat(b) - - // Collect models from all providers compatible with the chosen backend. - var models []string - for _, p := range providers { - for _, k := range keys { - if p.Compatibility[k] { - models = append(models, p.Models...) - break - } - } - } - - env := make(map[string]string) - type modelVar struct { - substr string - envKey string - } - targets := []modelVar{ - {"opus", "ANTHROPIC_DEFAULT_OPUS_MODEL"}, - {"sonnet", "ANTHROPIC_DEFAULT_SONNET_MODEL"}, - {"haiku", "ANTHROPIC_DEFAULT_HAIKU_MODEL"}, - } - - sort.Sort(sort.Reverse(sort.StringSlice(models))) - for _, m := range models { - lower := strings.ToLower(m) - for _, t := range targets { - if _, ok := env[t.envKey]; !ok && strings.Contains(lower, t.substr) { - env[t.envKey] = m - } - } - } - return env -} - -func (c *ClaudeCodeProfile) ApplyModel(model string, env map[string]string) { - // model arrives as the FQN "providerID/modelID". For Bedrock the model is - // embedded in the request URL path, so a stray prefix breaks routing - // (e.g. /bedrock/model/bedrock//invoke-with-response-stream). - if i := strings.Index(model, "/"); i >= 0 { - model = model[i+1:] - } - env["ANTHROPIC_MODEL"] = model -} - -// WantsModelSelection skips the model picker for Bedrock, where Claude Code -// resolves models from ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL set by -// ProviderEnv and the user can switch with /model at runtime. -func (c *ClaudeCodeProfile) WantsModelSelection(b Backend) bool { - return b.Type != BackendBedrock -} - -// managedEnvVars returns every environment variable name that the launcher -// may set when launching Claude Code, across all backends. -var managedEnvVars = []string{ - "ANTHROPIC_BASE_URL", - "ANTHROPIC_MODEL", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_BEDROCK_BASE_URL", - "CLAUDE_CODE_USE_BEDROCK", - "CLAUDE_CODE_SKIP_BEDROCK_AUTH", - "CLOUD_ML_REGION", - "CLAUDE_CODE_USE_VERTEX", - "CLAUDE_CODE_SKIP_VERTEX_AUTH", - "ANTHROPIC_VERTEX_PROJECT_ID", - "ANTHROPIC_VERTEX_BASE_URL", - "ANTHROPIC_DEFAULT_OPUS_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "API_TIMEOUT_MS", - "ANTHROPIC_API_KEY", -} - -// Check validates that ~/.claude/settings.json does not set environment -// variables that conflict with what the launcher manages. Claude Code -// applies env from settings.json on startup, which would override the -// values the launcher injects via the process environment. -func (c *ClaudeCodeProfile) Check(b Backend) error { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("cannot determine home directory: %w", err) - } - - settingsPath := filepath.Join(home, ".claude", "settings.json") - data, err := os.ReadFile(settingsPath) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("cannot read %s\n\nCheck file permissions and try again", settingsPath) - } - - var settings struct { - Env map[string]any `json:"env"` - } - if err := json.Unmarshal(data, &settings); err != nil { - return fmt.Errorf("%s contains invalid JSON\n\nFix the syntax or delete the file and let Claude Code recreate it", settingsPath) - } - if len(settings.Env) == 0 { - return nil - } - - var conflicts []string - for _, key := range managedEnvVars { - if _, ok := settings.Env[key]; ok { - conflicts = append(conflicts, key) - } - } - if len(conflicts) == 0 { - return nil - } - - return fmt.Errorf( - "~/.claude/settings.json sets env vars that conflict with the launcher:\n\n %s\n\n"+ - "The launcher manages these variables automatically.\n"+ - "Remove them from the \"env\" section of ~/.claude/settings.json", - strings.Join(conflicts, "\n "), - ) -} - -func (c *ClaudeCodeProfile) Env(apertureHost string, b Backend) (map[string]string, error) { - switch b.Type { - case BackendAnthropic: - return map[string]string{ - "ANTHROPIC_BASE_URL": apertureHost, - "ANTHROPIC_AUTH_TOKEN": "-", - }, nil - case BackendBedrock: - return map[string]string{ - "ANTHROPIC_BEDROCK_BASE_URL": apertureHost + "/bedrock", - "CLAUDE_CODE_USE_BEDROCK": "1", - "CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1", - }, nil - case BackendVertex: - return map[string]string{ - "CLOUD_ML_REGION": "_aperture_auto_vertex_region_", - "CLAUDE_CODE_USE_VERTEX": "1", - "CLAUDE_CODE_SKIP_VERTEX_AUTH": "1", - "ANTHROPIC_VERTEX_PROJECT_ID": "_aperture_auto_vertex_project_id_", - "ANTHROPIC_VERTEX_BASE_URL": apertureHost + "/v1", - }, nil - case BackendZAI: - return map[string]string{ - "ANTHROPIC_BASE_URL": apertureHost, - "ANTHROPIC_MODEL": "glm-5.1", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.1", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5-turbo", - "API_TIMEOUT_MS": "3000000", - "ANTHROPIC_API_KEY": "-", - }, nil - default: - return nil, fmt.Errorf("unsupported backend %q for Claude Code", b.Type) - } -} diff --git a/internal/profiles/claude_desktop_test.go b/internal/profiles/claude_desktop_test.go new file mode 100644 index 0000000..88b9374 --- /dev/null +++ b/internal/profiles/claude_desktop_test.go @@ -0,0 +1,21 @@ +package profiles + +import "testing" + +func TestGatewayURL(t *testing.T) { + tests := []struct { + input, want string + }{ + {"http://ai", "https://ai"}, + {"https://my-aperture.ts.net", "https://my-aperture.ts.net"}, + {"http://ai/", "https://ai"}, + {"https://aperture.example.com/", "https://aperture.example.com"}, + {"ai.example.com", "https://ai.example.com"}, + {"http://ai:8080/", "https://ai:8080"}, + } + for _, tt := range tests { + if got := GatewayURL(tt.input); got != tt.want { + t.Errorf("GatewayURL(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/profiles/codex.go b/internal/profiles/codex.go deleted file mode 100644 index 553cbf4..0000000 --- a/internal/profiles/codex.go +++ /dev/null @@ -1,121 +0,0 @@ -package profiles - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" -) - -// CodexProfile implements Profile for the OpenAI `codex` CLI tool. -type CodexProfile struct{} - -func (c *CodexProfile) Name() string { return "OpenAI Codex" } - -func (c *CodexProfile) BinaryName() string { return "codex" } - -func (c *CodexProfile) CommonPaths() []string { - home, err := os.UserHomeDir() - if err != nil { - return nil - } - return []string{ - filepath.Join(home, ".local", "bin", "codex"), - } -} - -func (c *CodexProfile) SupportedBackends() []Backend { - return []Backend{ - {Type: BackendOpenAI, DisplayName: "OpenAI Compatible"}, - } -} - -func (c *CodexProfile) InstallHint() string { - return "npm install -g @openai/codex" -} - -func (c *CodexProfile) UninstallHint() string { - return "npm uninstall -g @openai/codex" -} - -func (c *CodexProfile) Uninstall() func() error { - return func() error { - return exec.Command("npm", "uninstall", "-g", "@openai/codex").Run() - } -} - -func (c *CodexProfile) YoloArgs() []string { - return []string{"--dangerously-bypass-approvals-and-sandbox"} -} - -func (c *CodexProfile) RequiredCompat(b Backend) []string { - switch b.Type { - case BackendOpenAI: - return []string{"openai_responses"} - default: - return nil - } -} - -func (c *CodexProfile) ApplyModel(model string, env map[string]string) { - env["OPENAI_MODEL"] = model -} - -func (c *CodexProfile) ModelArgs(model string) []string { - if model == "" { - return nil - } - return []string{"--model", model} -} - -func (c *CodexProfile) Env(apertureHost string, b Backend) (map[string]string, error) { - switch b.Type { - case BackendOpenAI: - return map[string]string{ - "OPENAI_BASE_URL": apertureHost + "/v1", - "OPENAI_API_KEY": "not-needed", - }, nil - default: - return nil, fmt.Errorf("unsupported backend %q for Codex", b.Type) - } -} - -// WriteConfig creates a persistent CODEX_HOME with auth.json pre-populated -// so Codex does not prompt for interactive login on first run. -func (c *CodexProfile) WriteConfig(apertureHost string, _ Backend) (envKey, configPath string, cleanup func(), err error) { - cfgDir, err := os.UserConfigDir() - if err != nil { - return "", "", nil, err - } - codexHome := filepath.Join(cfgDir, "aperture", "codex-home") - if err := os.MkdirAll(codexHome, 0o700); err != nil { - return "", "", nil, err - } - - auth := map[string]any{ - "auth_mode": "apikey", - "OPENAI_API_KEY": "not-needed", - } - data, err := json.MarshalIndent(auth, "", " ") - if err != nil { - return "", "", nil, err - } - if err := os.WriteFile(filepath.Join(codexHome, "auth.json"), data, 0o600); err != nil { - return "", "", nil, err - } - - baseURL := apertureHost + "/v1" - cfg := "model_provider = \"aperture\"\n\n" + - "[model_providers.aperture]\n" + - "name = \"Aperture\"\n" + - "base_url = " + strconv.Quote(baseURL) + "\n" + - "env_key = \"OPENAI_API_KEY\"\n" + - "supports_websockets = false\n" - if err := os.WriteFile(filepath.Join(codexHome, "config.toml"), []byte(cfg), 0o600); err != nil { - return "", "", nil, err - } - - return "CODEX_HOME", codexHome, func() {}, nil -} diff --git a/internal/profiles/gemini_cli.go b/internal/profiles/gemini_cli.go deleted file mode 100644 index 526e0b2..0000000 --- a/internal/profiles/gemini_cli.go +++ /dev/null @@ -1,116 +0,0 @@ -package profiles - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" -) - -// GeminiCLIProfile implements Profile for the `gemini` CLI tool. -type GeminiCLIProfile struct{} - -func (g *GeminiCLIProfile) Name() string { return "Gemini CLI" } - -func (g *GeminiCLIProfile) BinaryName() string { return "gemini" } - -func (g *GeminiCLIProfile) CommonPaths() []string { - home, err := os.UserHomeDir() - if err != nil { - return nil - } - return []string{ - filepath.Join(home, ".local", "bin", "gemini"), - } -} - -func (g *GeminiCLIProfile) SupportedBackends() []Backend { - return []Backend{ - {Type: BackendVertex, DisplayName: "Google Vertex"}, - {Type: BackendGemini, DisplayName: "Gemini API"}, - } -} - -func (g *GeminiCLIProfile) InstallHint() string { - return "npm install -g @google/gemini-cli" -} - -func (g *GeminiCLIProfile) UninstallHint() string { - return "npm uninstall -g @google/gemini-cli" -} - -func (g *GeminiCLIProfile) Uninstall() func() error { - return func() error { - return exec.Command("npm", "uninstall", "-g", "@google/gemini-cli").Run() - } -} - -func (g *GeminiCLIProfile) RequiredCompat(b Backend) []string { - switch b.Type { - case BackendVertex: - return []string{"experimental_gemini_cli_vertex_compat"} - case BackendGemini: - return []string{"gemini_generate_content"} - default: - return nil - } -} - -func (g *GeminiCLIProfile) WriteConfig(_ string, b Backend) (envKey, configPath string, cleanup func(), err error) { - var selectedType string - switch b.Type { - case BackendVertex: - selectedType = "vertex-ai" - case BackendGemini: - selectedType = "gemini-api-key" - default: - return "", "", func() {}, nil - } - - cfgDir, err := os.UserConfigDir() - if err != nil { - return "", "", nil, err - } - geminiHome := filepath.Join(cfgDir, "aperture", "gemini-home") - geminiDir := filepath.Join(geminiHome, ".gemini") - if err := os.MkdirAll(geminiDir, 0o700); err != nil { - return "", "", nil, err - } - settings := map[string]any{ - "security": map[string]any{ - "auth": map[string]any{ - "selectedType": selectedType, - }, - }, - } - data, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return "", "", nil, err - } - if err := os.WriteFile(filepath.Join(geminiDir, "settings.json"), data, 0o600); err != nil { - return "", "", nil, err - } - return "GEMINI_CLI_HOME", geminiHome, func() {}, nil -} - -func (g *GeminiCLIProfile) YoloArgs() []string { - return []string{"--yolo"} -} - -func (g *GeminiCLIProfile) Env(apertureHost string, b Backend) (map[string]string, error) { - switch b.Type { - case BackendVertex: - return map[string]string{ - "GOOGLE_VERTEX_BASE_URL": apertureHost, - "GOOGLE_API_KEY": "not-needed", - }, nil - case BackendGemini: - return map[string]string{ - "GEMINI_API_KEY": "not-needed", - "GEMINI_BASE_URL": apertureHost, - }, nil - default: - return nil, fmt.Errorf("unsupported backend %q for Gemini CLI", b.Type) - } -} diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index e914c2c..f58a27e 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -1,449 +1,21 @@ +// Package profiles is the vestigial home of the Claude Desktop (Claude +// Cowork) support. Every other agent has been ported to internal/clients; +// this package stays until Claude Desktop is ported too. It exposes a +// lightweight clients.Client adapter via Client() so the rest of the app +// sees one unified registry. package profiles -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" -) - -// BackendType identifies the upstream LLM provider. +// BackendType identifies the upstream Claude Desktop routes to. type BackendType string const ( + // BackendAnthropic is the only backend Claude Desktop supports. BackendAnthropic BackendType = "anthropic" - BackendBedrock BackendType = "bedrock" - BackendVertex BackendType = "vertex" - BackendGemini BackendType = "gemini" - BackendOpenAI BackendType = "openai" - BackendZAI BackendType = "zai" ) -// Backend is a selectable upstream destination. +// Backend is a selectable upstream destination. Kept for Claude Desktop's +// internal bookkeeping; not exposed outside this package. type Backend struct { Type BackendType DisplayName string } - -// Profile describes one AI coding agent. -type Profile interface { - Name() string - BinaryName() string - SupportedBackends() []Backend - Env(apertureHost string, b Backend) (map[string]string, error) -} - -// ConfigWriter is implemented by profiles that need a temporary config file -// written before launch. envKey is the environment variable name to set to -// configPath. The returned cleanup func removes the file or directory. -type ConfigWriter interface { - WriteConfig(apertureHost string, b Backend) (envKey, configPath string, cleanup func(), err error) -} - -// ProviderConfigWriter is implemented by profiles that generate a config file -// tailored to a specific provider (its ID, name, model list, and the HTTP -// protocol implied by its compatibility map). When a profile implements this -// interface, the TUI prefers it over ConfigWriter. -type ProviderConfigWriter interface { - WriteProviderConfig(apertureHost string, b Backend, p ProviderInfo) (envKey, configPath string, cleanup func(), err error) -} - -// YoloProfile is implemented by profiles that support a "skip permissions" -// flag. The returned args are appended to the command when YOLO mode is on. -type YoloProfile interface { - YoloArgs() []string -} - -// ProviderInfo mirrors the JSON response from GET /api/providers. -type ProviderInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Models []string `json:"models"` - Compatibility map[string]bool `json:"compatibility"` -} - -// DisplayName returns the provider's Name, falling back to ID if Name is empty. -func (p ProviderInfo) DisplayName() string { - if p.Name != "" { - return p.Name - } - return p.ID -} - -// CompatChecker is implemented by profiles that declare which API -// compatibility keys they require for each backend. The TUI uses this -// to hide backends that no provider supports. -type CompatChecker interface { - RequiredCompat(b Backend) []string -} - -// ProviderEnvSetter is implemented by profiles that derive additional -// environment variables from provider metadata (e.g. model names). -type ProviderEnvSetter interface { - ProviderEnv(b Backend, providers []ProviderInfo) map[string]string -} - -// ModelSelector is implemented by profiles that can apply a user-chosen -// default model to their environment variables. -type ModelSelector interface { - ApplyModel(model string, env map[string]string) -} - -// BackendModelSelector lets a ModelSelector profile opt out of the model -// picker for specific backends (e.g. when the backend determines the model -// from per-tier env vars set in ProviderEnv). When unimplemented, the TUI -// shows the picker for any backend if the profile implements ModelSelector. -type BackendModelSelector interface { - WantsModelSelection(b Backend) bool -} - -// ModelArgSelector is implemented by profiles that need a user-chosen -// default model passed as command-line arguments. -type ModelArgSelector interface { - ModelArgs(model string) []string -} - -// Combo is a resolved (profile, backend) pair. -type Combo struct { - Profile Profile - Backend Backend -} - -// Manager holds all known profiles and resolves which are installed. -type Manager struct { - profiles []Profile -} - -// NewManager returns a Manager with all built-in profiles registered. -func NewManager() *Manager { - p := []Profile{ - &ClaudeCodeProfile{}, - &GeminiCLIProfile{}, - &OpenCodeProfile{}, - &CodexProfile{}, - } - if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { - p = append(p, &ClaudeDesktopProfile{}) - } - return &Manager{profiles: p} -} - -// PathHinter is implemented by profiles that know common filesystem -// locations where their binary may be installed. These paths are checked -// as a fallback when the binary is not found on the current PATH (e.g. -// after a fresh install that updated shell profiles but the running -// process still has the old PATH). -type PathHinter interface { - // CommonPaths returns absolute paths where the binary is commonly - // installed. The returned paths should include the binary name - // (e.g. "~/.local/bin/claude", not just "~/.local/bin"). - CommonPaths() []string -} - -// commonBinDirs returns well-known user-local directories where binaries are -// commonly installed but may not be on PATH yet (e.g. after a fresh install -// that updated shell profiles but the running process still has the old PATH). -// System-wide directories like /usr/local/bin and /opt/homebrew/bin are -// intentionally excluded: binaries there will be found by exec.LookPath. -func commonBinDirs() []string { - home, err := os.UserHomeDir() - if err != nil { - return nil - } - return []string{ - filepath.Join(home, ".local", "bin"), - filepath.Join(home, "bin"), - filepath.Join(home, ".npm-global", "bin"), - } -} - -// IsInstalled reports whether the binary for a profile can be found, -// checking PATH first and then common installation directories. -func IsInstalled(p Profile) bool { - if p.BinaryName() == "" { - return true - } - return FindBinary(p) != "" -} - -// FindBinary returns the resolved path to a profile's binary. It checks -// exec.LookPath (i.e. $PATH) first, then profile-specific common paths, -// then general well-known binary directories. Returns "" if not found. -func FindBinary(p Profile) string { - name := p.BinaryName() - if name == "" { - return "" - } - - // Fast path: binary is on the current PATH. - if path, err := exec.LookPath(name); err == nil { - return path - } - - // Check profile-specific common installation paths. - if ph, ok := p.(PathHinter); ok { - for _, path := range ph.CommonPaths() { - if isExecutable(path) { - return path - } - } - } - - // Check general well-known binary directories. - for _, dir := range commonBinDirs() { - path := filepath.Join(dir, name) - if isExecutable(path) { - return path - } - } - - return "" -} - -// isExecutable reports whether the file at path exists and is executable. -func isExecutable(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - if info.IsDir() { - return false - } - // On Windows, permission bits are not meaningful; check the file extension. - if runtime.GOOS == "windows" { - ext := strings.ToLower(filepath.Ext(path)) - return ext == ".exe" || ext == ".cmd" || ext == ".bat" || ext == ".com" - } - // On Unix, check that at least one execute bit is set. - return info.Mode()&0o111 != 0 -} - -// Installer is implemented by profiles that can provide installation -// instructions when their binary is not found on PATH. -type Installer interface { - InstallHint() string -} - -// Checker is implemented by profiles that need to validate the local -// environment before launching (e.g. checking config files). -type Checker interface { - Check(b Backend) error -} - -// Uninstaller is implemented by profiles that can provide uninstall support. -// UninstallHint returns a human-readable description shown before the user -// confirms. Uninstall returns the function that performs the actual removal. -type Uninstaller interface { - UninstallHint() string - Uninstall() func() error -} - -// Launcher is implemented by profiles that launch a desktop application -// rather than a CLI tool. Launch may update configuration before starting -// the app, and returns immediately after launch. -type Launcher interface { - Launch(apertureHost string) error -} - -// HostAwareInstaller is implemented by profiles whose installation requires -// the aperture host URL (e.g. to write platform config alongside the binary -// install). RunInstall writes any platform config and returns an exec.Cmd -// that downloads and runs the installer. The TUI executes the command with -// terminal takeover so the user sees download progress. -type HostAwareInstaller interface { - RunInstall(apertureHost string) (*exec.Cmd, error) -} - -// AllProfiles returns all registered profiles regardless of installation status. -func (m *Manager) AllProfiles() []Profile { - return m.profiles -} - -// FilteredBackends returns the backends for a profile filtered by provider -// compatibility. If providers is nil, no filtering is applied. -func (m *Manager) FilteredBackends(p Profile, providers []ProviderInfo) []Backend { - if providers == nil { - return p.SupportedBackends() - } - checker, ok := p.(CompatChecker) - if !ok { - return p.SupportedBackends() - } - var out []Backend - for _, b := range p.SupportedBackends() { - keys := checker.RequiredCompat(b) - if anyProviderSupports(providers, keys) { - out = append(out, b) - } - } - return out -} - -// anyProviderSupports reports whether at least one provider has any of the -// given compatibility keys set to true. -func anyProviderSupports(providers []ProviderInfo, keys []string) bool { - for _, p := range providers { - for _, k := range keys { - if p.Compatibility[k] { - return true - } - } - } - return false -} - -// CompatibleProviders returns providers whose Compatibility map has at least one -// key matching any of the profile's backends' RequiredCompat keys. If the -// profile does not implement CompatChecker, all providers are returned. -func (m *Manager) CompatibleProviders(p Profile, providers []ProviderInfo) []ProviderInfo { - checker, ok := p.(CompatChecker) - if !ok { - return providers - } - var out []ProviderInfo - for _, prov := range providers { - if providerMatchesProfile(prov, p, checker) { - out = append(out, prov) - } - } - return out -} - -// providerMatchesProfile reports whether the provider's Compatibility map -// has at least one key that matches one of the profile's backends' RequiredCompat keys. -func providerMatchesProfile(prov ProviderInfo, p Profile, checker CompatChecker) bool { - for _, b := range p.SupportedBackends() { - for _, key := range checker.RequiredCompat(b) { - if prov.Compatibility[key] { - return true - } - } - } - return false -} - -// BackendsForProvider returns the backends of a profile that are supported by -// a specific provider. If the profile does not implement CompatChecker, all -// supported backends are returned. -func (m *Manager) BackendsForProvider(p Profile, provider ProviderInfo) []Backend { - checker, ok := p.(CompatChecker) - if !ok { - return p.SupportedBackends() - } - var out []Backend - for _, b := range p.SupportedBackends() { - for _, key := range checker.RequiredCompat(b) { - if provider.Compatibility[key] { - out = append(out, b) - break - } - } - } - return out -} - -// DedupBackends removes backends with identical compat key signatures, -// keeping only the first backend for each unique signature. This avoids -// showing the user multiple backends that are functionally equivalent -// (e.g. Anthropic and ZAI both require only "anthropic_messages"). -func (m *Manager) DedupBackends(p Profile, backends []Backend) []Backend { - checker, ok := p.(CompatChecker) - if !ok { - return backends - } - seen := make(map[string]bool) - var out []Backend - for _, b := range backends { - sig := compatKeySig(checker.RequiredCompat(b)) - if !seen[sig] { - seen[sig] = true - out = append(out, b) - } - } - return out -} - -func compatKeySig(keys []string) string { - return strings.Join(keys, ",") -} - -// ValidCombos returns all (profile, backend) combos where the profile binary -// is present on PATH. If providers is non-nil, backends are filtered by -// provider compatibility. -func (m *Manager) ValidCombos(providers []ProviderInfo) []Combo { - var combos []Combo - for _, p := range m.profiles { - if !IsInstalled(p) { - continue - } - for _, b := range m.FilteredBackends(p, providers) { - combos = append(combos, Combo{Profile: p, Backend: b}) - } - } - return combos -} - -// InstalledProfiles returns only the profiles whose binary is on PATH. -func (m *Manager) InstalledProfiles() []Profile { - var out []Profile - for _, p := range m.profiles { - if IsInstalled(p) { - out = append(out, p) - } - } - return out -} - -// StateFile records the last-used profile/backend for quick re-launch. -type StateFile struct { - LastProfileName string `json:"lastProfileName,omitempty"` - LastBackendType string `json:"lastBackendType,omitempty"` - LastProviderID string `json:"lastProviderId,omitempty"` - LastModel string `json:"lastModel,omitempty"` -} - -// statePath returns the path to the launcher state JSON file. -func statePath() (string, error) { - dir, err := os.UserConfigDir() - if err != nil { - return "", err - } - return filepath.Join(dir, "aperture", "launcher.json"), nil -} - -// LoadState reads the persisted launcher state. Errors are silently ignored -// and an empty StateFile is returned. -func LoadState() (StateFile, error) { - path, err := statePath() - if err != nil { - return StateFile{}, nil - } - data, err := os.ReadFile(path) - if err != nil { - return StateFile{}, nil - } - var s StateFile - if err := json.Unmarshal(data, &s); err != nil { - return StateFile{}, nil - } - return s, nil -} - -// SaveState persists the launcher state to disk. -func SaveState(s StateFile) error { - path, err := statePath() - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return err - } - data, err := json.Marshal(s) - if err != nil { - return err - } - return os.WriteFile(path, data, 0o600) -} diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go deleted file mode 100644 index 1100bab..0000000 --- a/internal/profiles/profiles_test.go +++ /dev/null @@ -1,1075 +0,0 @@ -package profiles_test - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - "github.com/tailscale/aperture-cli/internal/profiles" -) - -const testHost = "http://ai.example.com" - -func TestLauncher_ClaudeCode_Env_Anthropic(t *testing.T) { - p := &profiles.ClaudeCodeProfile{} - backends := p.SupportedBackends() - var b profiles.Backend - for _, bb := range backends { - if bb.Type == profiles.BackendAnthropic { - b = bb - break - } - } - if b.Type == "" { - t.Fatal("BackendAnthropic not in SupportedBackends") - } - - env, err := p.Env(testHost, b) - if err != nil { - t.Fatalf("Env returned error: %v", err) - } - - if got := env["ANTHROPIC_BASE_URL"]; got != testHost { - t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", got, testHost) - } - if got := env["ANTHROPIC_AUTH_TOKEN"]; got != "-" { - t.Errorf("ANTHROPIC_AUTH_TOKEN = %q, want %q", got, "-") - } -} - -func TestLauncher_ClaudeCode_Env_Bedrock(t *testing.T) { - p := &profiles.ClaudeCodeProfile{} - b := profiles.Backend{Type: profiles.BackendBedrock, DisplayName: "AWS Bedrock"} - - env, err := p.Env(testHost, b) - if err != nil { - t.Fatalf("Env returned error: %v", err) - } - - want := map[string]string{ - "ANTHROPIC_BEDROCK_BASE_URL": testHost + "/bedrock", - "CLAUDE_CODE_USE_BEDROCK": "1", - "CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1", - } - for k, wantV := range want { - if got := env[k]; got != wantV { - t.Errorf("%s = %q, want %q", k, got, wantV) - } - } -} - -func TestLauncher_ClaudeCode_Env_Vertex(t *testing.T) { - p := &profiles.ClaudeCodeProfile{} - b := profiles.Backend{Type: profiles.BackendVertex, DisplayName: "Google Vertex"} - - env, err := p.Env(testHost, b) - if err != nil { - t.Fatalf("Env returned error: %v", err) - } - - want := map[string]string{ - "CLOUD_ML_REGION": "_aperture_auto_vertex_region_", - "CLAUDE_CODE_USE_VERTEX": "1", - "ANTHROPIC_VERTEX_PROJECT_ID": "_aperture_auto_vertex_project_id_", - "ANTHROPIC_VERTEX_BASE_URL": testHost + "/v1", - } - for k, wantV := range want { - if got := env[k]; got != wantV { - t.Errorf("%s = %q, want %q", k, got, wantV) - } - } -} - -func TestLauncher_ClaudeCode_Env_ZAI(t *testing.T) { - p := &profiles.ClaudeCodeProfile{} - b := profiles.Backend{Type: profiles.BackendZAI, DisplayName: "z.ai"} - - env, err := p.Env(testHost, b) - if err != nil { - t.Fatalf("Env returned error: %v", err) - } - - want := map[string]string{ - "ANTHROPIC_BASE_URL": testHost, - "ANTHROPIC_MODEL": "glm-5.1", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.1", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5-turbo", - "API_TIMEOUT_MS": "3000000", - "ANTHROPIC_API_KEY": "-", - } - for k, wantV := range want { - if got := env[k]; got != wantV { - t.Errorf("%s = %q, want %q", k, got, wantV) - } - } -} - -func TestLauncher_GeminiCLI_Env_Vertex(t *testing.T) { - p := &profiles.GeminiCLIProfile{} - b := profiles.Backend{Type: profiles.BackendVertex, DisplayName: "Google Vertex"} - - env, err := p.Env(testHost, b) - if err != nil { - t.Fatalf("Env returned error: %v", err) - } - - want := map[string]string{ - "GOOGLE_VERTEX_BASE_URL": testHost, - "GOOGLE_API_KEY": "not-needed", - } - for k, wantV := range want { - if got := env[k]; got != wantV { - t.Errorf("%s = %q, want %q", k, got, wantV) - } - } -} - -func TestLauncher_StateFile_RoundTrip(t *testing.T) { - // Use a temp dir so we don't pollute the real config. - tmp := t.TempDir() - t.Setenv("HOME", tmp) - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg == "" { - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) - } - - want := profiles.StateFile{ - LastProfileName: "Claude Code", - LastBackendType: string(profiles.BackendBedrock), - } - if err := profiles.SaveState(want); err != nil { - t.Fatalf("SaveState: %v", err) - } - - got, err := profiles.LoadState() - if err != nil { - t.Fatalf("LoadState: %v", err) - } - - if got.LastProfileName != want.LastProfileName { - t.Errorf("LastProfileName = %q, want %q", got.LastProfileName, want.LastProfileName) - } - if got.LastBackendType != want.LastBackendType { - t.Errorf("LastBackendType = %q, want %q", got.LastBackendType, want.LastBackendType) - } -} - -func TestLauncher_OpenCode_SupportedBackends_Single(t *testing.T) { - p := &profiles.OpenCodeProfile{} - if got := p.SupportedBackends(); len(got) != 1 { - t.Errorf("SupportedBackends len = %d, want 1", len(got)) - } -} - -func TestLauncher_OpenCode_ProviderEnv(t *testing.T) { - p := &profiles.OpenCodeProfile{} - b := profiles.Backend{Type: profiles.BackendOpenAI} - - bedrock := profiles.ProviderInfo{ - ID: "bedrock", Compatibility: map[string]bool{"bedrock_converse": true}, - } - env := p.ProviderEnv(b, []profiles.ProviderInfo{bedrock}) - if env["AWS_ACCESS_KEY_ID"] != "not-needed" || env["AWS_REGION"] != "us-east-1" { - t.Errorf("bedrock ProviderEnv = %v", env) - } - - vertex := profiles.ProviderInfo{ - ID: "vertex", Compatibility: map[string]bool{"google_generate_content": true}, - } - if env := p.ProviderEnv(b, []profiles.ProviderInfo{vertex}); len(env) != 0 { - t.Errorf("vertex ProviderEnv = %v, want empty (express mode)", env) - } - - anthropic := profiles.ProviderInfo{ - ID: "anthropic", Compatibility: map[string]bool{"anthropic_messages": true}, - } - if env := p.ProviderEnv(b, []profiles.ProviderInfo{anthropic}); len(env) != 0 { - t.Errorf("anthropic ProviderEnv = %v, want empty", env) - } -} - -func TestLauncher_OpenCode_WriteProviderConfig(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) - - p := &profiles.OpenCodeProfile{} - - cw, ok := profiles.Profile(p).(profiles.ProviderConfigWriter) - if !ok { - t.Fatal("OpenCodeProfile does not implement ProviderConfigWriter") - } - - tests := []struct { - name string - provider profiles.ProviderInfo - wantNPM string - wantOptions map[string]string - }{ - { - name: "anthropic_messages", - provider: profiles.ProviderInfo{ - ID: "anthropic", Name: "Anthropic", - Models: []string{"claude-sonnet-4-5", "claude-haiku-4-5"}, - Compatibility: map[string]bool{"anthropic_messages": true}, - }, - wantNPM: "@ai-sdk/anthropic", - wantOptions: map[string]string{ - "baseURL": testHost + "/v1", - "apiKey": "not-required", - }, - }, - { - name: "bedrock_converse", - provider: profiles.ProviderInfo{ - ID: "bedrock", Name: "AWS Bedrock", - Models: []string{"us.anthropic.claude-opus-4-7"}, - Compatibility: map[string]bool{"bedrock_converse": true}, - }, - wantNPM: "@ai-sdk/amazon-bedrock", - wantOptions: map[string]string{ - "region": "us-east-1", - "endpoint": testHost + "/bedrock", - }, - }, - { - name: "google_generate_content", - provider: profiles.ProviderInfo{ - ID: "vertex", Name: "Vertex", - Models: []string{"gemini-2.5-pro"}, - Compatibility: map[string]bool{ - "google_generate_content": true, - "google_raw_predict": true, - }, - }, - wantNPM: "@ai-sdk/google-vertex", - wantOptions: map[string]string{ - "apiKey": "not-required", - "baseURL": testHost + "/v1/projects/_aperture_auto_vertex_project_id_/locations/_aperture_auto_vertex_region_/publishers/google", - }, - }, - { - name: "openai_responses", - provider: profiles.ProviderInfo{ - ID: "openai", Name: "OpenAI", - Models: []string{"gpt-5"}, - Compatibility: map[string]bool{ - "openai_chat": true, - "openai_responses": true, - }, - }, - wantNPM: "@ai-sdk/openai", - wantOptions: map[string]string{ - "baseURL": testHost + "/v1", - "apiKey": "not-required", - }, - }, - { - name: "openai_chat_only", - provider: profiles.ProviderInfo{ - ID: "openrouter", Name: "OpenRouter", - Models: []string{"qwen/qwen3-235b-a22b-2507"}, - Compatibility: map[string]bool{"openai_chat": true}, - }, - wantNPM: "@ai-sdk/openai-compatible", - wantOptions: map[string]string{ - "baseURL": testHost + "/v1", - "apiKey": "not-required", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - envKey, configPath, cleanup, err := cw.WriteProviderConfig(testHost, profiles.Backend{Type: profiles.BackendOpenAI}, tt.provider) - if err != nil { - t.Fatalf("WriteProviderConfig returned error: %v", err) - } - if envKey != "OPENCODE_CONFIG" { - t.Errorf("envKey = %q, want OPENCODE_CONFIG", envKey) - } - - data, err := os.ReadFile(configPath) - if err != nil { - t.Fatalf("config file not readable: %v", err) - } - - var cfg struct { - Provider map[string]struct { - NPM string `json:"npm"` - Name string `json:"name"` - Options map[string]string `json:"options"` - Models map[string]map[string]string `json:"models"` - Whitelist []string `json:"whitelist"` - } `json:"provider"` - } - if err := json.Unmarshal(data, &cfg); err != nil { - t.Fatalf("config file is not valid JSON: %v", err) - } - - prov, ok := cfg.Provider[tt.provider.ID] - if !ok { - t.Fatalf("provider %q not found in config", tt.provider.ID) - } - if prov.NPM != tt.wantNPM { - t.Errorf("npm = %q, want %q", prov.NPM, tt.wantNPM) - } - wantName := "Aperture (" + tt.provider.ID + ")" - if prov.Name != wantName { - t.Errorf("name = %q, want %q", prov.Name, wantName) - } - for k, want := range tt.wantOptions { - if got := prov.Options[k]; got != want { - t.Errorf("options[%q] = %q, want %q", k, got, want) - } - } - if len(prov.Models) != len(tt.provider.Models) { - t.Errorf("models len = %d, want %d", len(prov.Models), len(tt.provider.Models)) - } - for _, m := range tt.provider.Models { - fqn := tt.provider.ID + "/" + m - entry, ok := prov.Models[fqn] - if !ok { - t.Errorf("model %q missing from config", fqn) - continue - } - if entry["id"] != m { - t.Errorf("model %q id = %q, want %q", fqn, entry["id"], m) - } - if entry["name"] != fqn { - t.Errorf("model %q name = %q, want %q", fqn, entry["name"], fqn) - } - } - if len(prov.Whitelist) != len(tt.provider.Models) { - t.Errorf("whitelist len = %d, want %d", len(prov.Whitelist), len(tt.provider.Models)) - } - for i, m := range tt.provider.Models { - fqn := tt.provider.ID + "/" + m - if i < len(prov.Whitelist) && prov.Whitelist[i] != fqn { - t.Errorf("whitelist[%d] = %q, want %q", i, prov.Whitelist[i], fqn) - } - } - - cleanup() - if _, err := os.Stat(configPath); !os.IsNotExist(err) { - t.Errorf("config file still exists after cleanup") - } - }) - } -} - -func TestLauncher_Codex_Env_OpenAI(t *testing.T) { - p := &profiles.CodexProfile{} - env, err := p.Env(testHost, profiles.Backend{Type: profiles.BackendOpenAI}) - if err != nil { - t.Fatalf("Env returned error: %v", err) - } - - want := map[string]string{ - "OPENAI_BASE_URL": testHost + "/v1", - "OPENAI_API_KEY": "not-needed", - } - for k, wantV := range want { - if got := env[k]; got != wantV { - t.Errorf("%s = %q, want %q", k, got, wantV) - } - } -} - -func TestLauncher_Codex_Env_UnsupportedBackend(t *testing.T) { - p := &profiles.CodexProfile{} - _, err := p.Env(testHost, profiles.Backend{Type: profiles.BackendAnthropic}) - if err == nil { - t.Fatal("expected error for unsupported backend, got nil") - } -} - -func TestLauncher_Codex_YoloArgs(t *testing.T) { - p := &profiles.CodexProfile{} - args := p.YoloArgs() - if len(args) != 1 || args[0] != "--dangerously-bypass-approvals-and-sandbox" { - t.Errorf("YoloArgs() = %v, want [--dangerously-bypass-approvals-and-sandbox]", args) - } -} - -func TestLauncher_Codex_ModelArgs(t *testing.T) { - p := &profiles.CodexProfile{} - args := p.ModelArgs("test-provider/gpt-5.3-codex") - want := []string{"--model", "test-provider/gpt-5.3-codex"} - if !reflect.DeepEqual(args, want) { - t.Errorf("ModelArgs() = %v, want %v", args, want) - } -} - -func TestLauncher_Codex_WriteConfig(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) - - p := &profiles.CodexProfile{} - - cw, ok := profiles.Profile(p).(profiles.ConfigWriter) - if !ok { - t.Fatal("CodexProfile does not implement ConfigWriter") - } - - envKey, configPath, cleanup, err := cw.WriteConfig(testHost, profiles.Backend{Type: profiles.BackendOpenAI}) - if err != nil { - t.Fatalf("WriteConfig returned error: %v", err) - } - defer cleanup() - - if envKey != "CODEX_HOME" { - t.Errorf("envKey = %q, want %q", envKey, "CODEX_HOME") - } - - authPath := filepath.Join(configPath, "auth.json") - data, err := os.ReadFile(authPath) - if err != nil { - t.Fatalf("auth.json not readable: %v", err) - } - - var auth map[string]string - if err := json.Unmarshal(data, &auth); err != nil { - t.Fatalf("auth.json is not valid JSON: %v", err) - } - if got := auth["auth_mode"]; got != "apikey" { - t.Errorf("auth_mode = %q, want %q", got, "apikey") - } - if got := auth["OPENAI_API_KEY"]; got != "not-needed" { - t.Errorf("OPENAI_API_KEY = %q, want %q", got, "not-needed") - } -} - -func TestLauncher_Codex_InstallHint(t *testing.T) { - p := &profiles.CodexProfile{} - want := "npm install -g @openai/codex" - if got := p.InstallHint(); got != want { - t.Errorf("InstallHint() = %q, want %q", got, want) - } -} - -func TestLauncher_ValidCombos_NoInstalledAgents(t *testing.T) { - // Put PATH to an empty dir so no binaries are found. - tmp := t.TempDir() - t.Setenv("PATH", tmp) - t.Setenv("HOME", tmp) - - mgr := profiles.NewManager() - combos := mgr.ValidCombos(nil) - - // All built-in profiles require a real binary, so with an empty PATH - // and a HOME with no binaries there should be zero combos. - if len(combos) != 0 { - t.Errorf("expected zero combos with empty PATH, got %d", len(combos)) - } -} - -func TestLauncher_FilteredBackends_MatchingProvider(t *testing.T) { - mgr := profiles.NewManager() - p := &profiles.ClaudeCodeProfile{} - providers := []profiles.ProviderInfo{ - { - ID: "test-provider", - Name: "Test", - Compatibility: map[string]bool{ - "anthropic_messages": true, - }, - }, - } - - backends := mgr.FilteredBackends(p, providers) - if len(backends) == 0 { - t.Fatal("expected at least one backend, got none") - } - - found := false - for _, b := range backends { - if b.Type == profiles.BackendAnthropic { - found = true - } - } - if !found { - t.Error("expected Anthropic backend to be kept, but it was filtered out") - } -} - -func TestLauncher_FilteredBackends_NoMatchingProvider(t *testing.T) { - mgr := profiles.NewManager() - p := &profiles.ClaudeCodeProfile{} - providers := []profiles.ProviderInfo{ - { - ID: "openai-only", - Name: "OpenAI Only", - Compatibility: map[string]bool{ - "openai_chat": true, - }, - }, - } - - backends := mgr.FilteredBackends(p, providers) - if len(backends) != 0 { - t.Errorf("expected zero backends for ClaudeCode with only openai_chat provider, got %d", len(backends)) - } -} - -func TestLauncher_FilteredBackends_NilProviders(t *testing.T) { - mgr := profiles.NewManager() - p := &profiles.ClaudeCodeProfile{} - - backends := mgr.FilteredBackends(p, nil) - if len(backends) != len(p.SupportedBackends()) { - t.Errorf("nil providers should return all backends; got %d, want %d", - len(backends), len(p.SupportedBackends())) - } -} - -func TestLauncher_RequiredCompat_OpenCode(t *testing.T) { - p := &profiles.OpenCodeProfile{} - keys := p.RequiredCompat(profiles.Backend{}) - if len(keys) == 0 { - t.Fatal("expected at least one compat key for OpenCode") - } - - // Verify that providers with any of the supported protocols appear as - // compatible for OpenCode. - mgr := profiles.NewManager() - for _, compat := range []string{"anthropic_messages", "bedrock_converse", "google_generate_content", "openai_chat"} { - providers := []profiles.ProviderInfo{ - {ID: "p", Compatibility: map[string]bool{compat: true}}, - } - if got := mgr.CompatibleProviders(p, providers); len(got) != 1 { - t.Errorf("provider with %q not compatible with OpenCode", compat) - } - } -} - -func TestLauncher_FindBinary_FallbackToCommonPaths(t *testing.T) { - // Set PATH to an empty dir so exec.LookPath won't find anything. - tmp := t.TempDir() - t.Setenv("PATH", tmp) - t.Setenv("HOME", tmp) - - // Create a fake binary at the OpenCode-specific common path. - binDir := filepath.Join(tmp, ".opencode", "bin") - if err := os.MkdirAll(binDir, 0o755); err != nil { - t.Fatal(err) - } - fakeBinary := filepath.Join(binDir, "opencode") - if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { - t.Fatal(err) - } - - p := &profiles.OpenCodeProfile{} - - // FindBinary should discover it via CommonPaths even though PATH is empty. - got := profiles.FindBinary(p) - if got != fakeBinary { - t.Errorf("FindBinary() = %q, want %q", got, fakeBinary) - } - - // IsInstalled should also return true. - if !profiles.IsInstalled(p) { - t.Error("IsInstalled() = false, want true") - } -} - -func TestLauncher_FindBinary_FallbackToGeneralBinDirs(t *testing.T) { - // Set PATH to an empty dir so exec.LookPath won't find anything. - tmp := t.TempDir() - t.Setenv("PATH", tmp) - t.Setenv("HOME", tmp) - - // Create a fake binary in ~/.local/bin (a general common bin dir). - localBin := filepath.Join(tmp, ".local", "bin") - if err := os.MkdirAll(localBin, 0o755); err != nil { - t.Fatal(err) - } - fakeBinary := filepath.Join(localBin, "claude") - if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { - t.Fatal(err) - } - - p := &profiles.ClaudeCodeProfile{} - - // ClaudeCodeProfile.CommonPaths includes ~/.local/bin/claude, so it - // should be found via the profile-specific path. - got := profiles.FindBinary(p) - if got != fakeBinary { - t.Errorf("FindBinary() = %q, want %q", got, fakeBinary) - } -} - -func TestLauncher_FindBinary_NotFound(t *testing.T) { - // Set PATH and HOME to an empty temp dir. - tmp := t.TempDir() - t.Setenv("PATH", tmp) - t.Setenv("HOME", tmp) - - p := &profiles.ClaudeCodeProfile{} - got := profiles.FindBinary(p) - if got != "" { - t.Errorf("FindBinary() = %q, want empty string", got) - } - if profiles.IsInstalled(p) { - t.Error("IsInstalled() = true, want false") - } -} - -func TestLauncher_FindBinary_PrefersPath(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - - // Create a fake binary on PATH. - pathBin := filepath.Join(tmp, "pathbin") - if err := os.MkdirAll(pathBin, 0o755); err != nil { - t.Fatal(err) - } - pathBinary := filepath.Join(pathBin, "opencode") - if err := os.WriteFile(pathBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { - t.Fatal(err) - } - - // Also create a binary at the common path. - commonBin := filepath.Join(tmp, ".opencode", "bin") - if err := os.MkdirAll(commonBin, 0o755); err != nil { - t.Fatal(err) - } - commonBinary := filepath.Join(commonBin, "opencode") - if err := os.WriteFile(commonBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { - t.Fatal(err) - } - - t.Setenv("PATH", pathBin) - - p := &profiles.OpenCodeProfile{} - got := profiles.FindBinary(p) - // Should prefer the PATH binary over the common path. - if got != pathBinary { - t.Errorf("FindBinary() = %q, want %q (PATH should be preferred)", got, pathBinary) - } -} - -func TestLauncher_FindBinary_SkipsNonExecutable(t *testing.T) { - tmp := t.TempDir() - t.Setenv("PATH", tmp) - t.Setenv("HOME", tmp) - - // Create a file at the common path but without execute permission. - binDir := filepath.Join(tmp, ".local", "bin") - if err := os.MkdirAll(binDir, 0o755); err != nil { - t.Fatal(err) - } - nonExec := filepath.Join(binDir, "claude") - if err := os.WriteFile(nonExec, []byte("not executable"), 0o644); err != nil { - t.Fatal(err) - } - - p := &profiles.ClaudeCodeProfile{} - got := profiles.FindBinary(p) - if got != "" { - t.Errorf("FindBinary() = %q, want empty string (file is not executable)", got) - } -} - -func TestLauncher_ClaudeCode_Check_NoSettingsFile(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - - p := &profiles.ClaudeCodeProfile{} - b := profiles.Backend{Type: profiles.BackendAnthropic} - if err := p.Check(b); err != nil { - t.Fatalf("Check returned error when settings.json missing: %v", err) - } -} - -func TestLauncher_ClaudeCode_Check_NoConflicts(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - - claudeDir := filepath.Join(tmp, ".claude") - if err := os.MkdirAll(claudeDir, 0o755); err != nil { - t.Fatal(err) - } - settings := `{"env":{"SOME_UNRELATED_VAR":"hello"}}` - if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte(settings), 0o644); err != nil { - t.Fatal(err) - } - - p := &profiles.ClaudeCodeProfile{} - b := profiles.Backend{Type: profiles.BackendAnthropic} - if err := p.Check(b); err != nil { - t.Fatalf("Check returned error with no conflicting vars: %v", err) - } -} - -func TestLauncher_ClaudeCode_Check_WithConflicts(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - - claudeDir := filepath.Join(tmp, ".claude") - if err := os.MkdirAll(claudeDir, 0o755); err != nil { - t.Fatal(err) - } - settings := `{"env":{"ANTHROPIC_BASE_URL":"https://example.com","CLAUDE_CODE_USE_BEDROCK":"1"}}` - if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte(settings), 0o644); err != nil { - t.Fatal(err) - } - - p := &profiles.ClaudeCodeProfile{} - b := profiles.Backend{Type: profiles.BackendAnthropic} - err := p.Check(b) - if err == nil { - t.Fatal("Check returned nil, expected error for conflicting vars") - } - - errMsg := err.Error() - if !strings.Contains(errMsg, "ANTHROPIC_BASE_URL") { - t.Errorf("error should mention ANTHROPIC_BASE_URL, got: %s", errMsg) - } - if !strings.Contains(errMsg, "CLAUDE_CODE_USE_BEDROCK") { - t.Errorf("error should mention CLAUDE_CODE_USE_BEDROCK, got: %s", errMsg) - } -} - -func TestLauncher_ClaudeCode_Check_InvalidJSON(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - - claudeDir := filepath.Join(tmp, ".claude") - if err := os.MkdirAll(claudeDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte("{not json}"), 0o644); err != nil { - t.Fatal(err) - } - - p := &profiles.ClaudeCodeProfile{} - b := profiles.Backend{Type: profiles.BackendAnthropic} - err := p.Check(b) - if err == nil { - t.Fatal("Check returned nil, expected error for invalid JSON") - } - if !strings.Contains(err.Error(), "invalid JSON") { - t.Errorf("error should mention invalid JSON, got: %s", err.Error()) - } -} - -func TestLauncher_ClaudeCode_Check_EmptyEnv(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - - claudeDir := filepath.Join(tmp, ".claude") - if err := os.MkdirAll(claudeDir, 0o755); err != nil { - t.Fatal(err) - } - settings := `{"env":{}}` - if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte(settings), 0o644); err != nil { - t.Fatal(err) - } - - p := &profiles.ClaudeCodeProfile{} - b := profiles.Backend{Type: profiles.BackendAnthropic} - if err := p.Check(b); err != nil { - t.Fatalf("Check returned error with empty env: %v", err) - } -} - -func TestLauncher_ClaudeDesktop_GatewayURL(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"http://ai", "https://ai"}, - {"https://my-aperture.ts.net", "https://my-aperture.ts.net"}, - {"http://ai/", "https://ai"}, - {"https://aperture.example.com/", "https://aperture.example.com"}, - {"ai.example.com", "https://ai.example.com"}, - {"http://ai:8080/", "https://ai:8080"}, - } - for _, tt := range tests { - got := profiles.GatewayURL(tt.input) - if got != tt.want { - t.Errorf("GatewayURL(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestLauncher_ClaudeDesktop_ImplementsLauncher(t *testing.T) { - p := &profiles.ClaudeDesktopProfile{} - if _, ok := profiles.Profile(p).(profiles.Launcher); !ok { - t.Fatal("ClaudeDesktopProfile does not implement Launcher") - } -} - -func TestLauncher_ClaudeDesktop_ImplementsHostAwareInstaller(t *testing.T) { - p := &profiles.ClaudeDesktopProfile{} - if _, ok := profiles.Profile(p).(profiles.HostAwareInstaller); !ok { - t.Fatal("ClaudeDesktopProfile does not implement HostAwareInstaller") - } -} - -func TestLauncher_ClaudeDesktop_SupportedBackends(t *testing.T) { - p := &profiles.ClaudeDesktopProfile{} - backends := p.SupportedBackends() - if len(backends) != 1 { - t.Fatalf("expected 1 backend, got %d", len(backends)) - } - if backends[0].Type != profiles.BackendAnthropic { - t.Errorf("backend type = %q, want %q", backends[0].Type, profiles.BackendAnthropic) - } -} - -func TestLauncher_CompatibleProviders(t *testing.T) { - mgr := profiles.NewManager() - p := &profiles.ClaudeCodeProfile{} - providers := []profiles.ProviderInfo{ - { - ID: "anthropic", - Name: "Anthropic", - Compatibility: map[string]bool{ - "anthropic_messages": true, - }, - }, - { - ID: "bedrock", - Name: "AWS Bedrock", - Compatibility: map[string]bool{ - "bedrock_model_invoke": true, - }, - }, - { - ID: "openai-only", - Name: "OpenAI Only", - Compatibility: map[string]bool{ - "openai_chat": true, - }, - }, - } - - compatible := mgr.CompatibleProviders(p, providers) - if len(compatible) != 2 { - t.Fatalf("expected 2 compatible providers, got %d", len(compatible)) - } - - gotIDs := make(map[string]bool) - for _, prov := range compatible { - gotIDs[prov.ID] = true - } - if !gotIDs["anthropic"] || !gotIDs["bedrock"] { - t.Errorf("expected anthropic and bedrock, got IDs: %v", compatible) - } -} - -func TestLauncher_CompatibleProviders_NoCompatChecker(t *testing.T) { - // Create a profile that does not implement CompatChecker. - mgr := profiles.NewManager() - p := &noCompatProfile{} - providers := []profiles.ProviderInfo{ - {ID: "a", Name: "A", Compatibility: map[string]bool{"x": true}}, - {ID: "b", Name: "B", Compatibility: map[string]bool{"y": true}}, - } - - compatible := mgr.CompatibleProviders(p, providers) - if len(compatible) != 2 { - t.Errorf("expected all providers returned for non-CompatChecker profile, got %d", len(compatible)) - } -} - -func TestLauncher_CompatibleProviders_NoMatch(t *testing.T) { - mgr := profiles.NewManager() - p := &profiles.ClaudeCodeProfile{} - providers := []profiles.ProviderInfo{ - { - ID: "openai-only", - Name: "OpenAI Only", - Compatibility: map[string]bool{ - "openai_chat": true, - }, - }, - } - - compatible := mgr.CompatibleProviders(p, providers) - if len(compatible) != 0 { - t.Errorf("expected 0 compatible providers, got %d", len(compatible)) - } -} - -func TestLauncher_BackendsForProvider(t *testing.T) { - mgr := profiles.NewManager() - p := &profiles.ClaudeCodeProfile{} - provider := profiles.ProviderInfo{ - ID: "multi", - Name: "Multi", - Compatibility: map[string]bool{ - "anthropic_messages": true, - "bedrock_model_invoke": true, - }, - } - - backends := mgr.BackendsForProvider(p, provider) - // anthropic_messages matches both Anthropic and ZAI backends; - // bedrock_model_invoke matches Bedrock. - if len(backends) != 3 { - t.Fatalf("expected 3 backends, got %d", len(backends)) - } - - gotTypes := make(map[profiles.BackendType]bool) - for _, b := range backends { - gotTypes[b.Type] = true - } - if !gotTypes[profiles.BackendAnthropic] || !gotTypes[profiles.BackendBedrock] || !gotTypes[profiles.BackendZAI] { - t.Errorf("expected anthropic, zai, and bedrock, got: %v", backends) - } -} - -func TestLauncher_BackendsForProvider_SingleBackend(t *testing.T) { - mgr := profiles.NewManager() - p := &profiles.ClaudeCodeProfile{} - provider := profiles.ProviderInfo{ - ID: "bedrock-only", - Name: "Bedrock Only", - Compatibility: map[string]bool{ - "bedrock_model_invoke": true, - }, - } - - backends := mgr.BackendsForProvider(p, provider) - if len(backends) != 1 { - t.Fatalf("expected 1 backend, got %d", len(backends)) - } - if backends[0].Type != profiles.BackendBedrock { - t.Errorf("backend type = %q, want %q", backends[0].Type, profiles.BackendBedrock) - } -} - -func TestLauncher_BackendsForProvider_NoCompatChecker(t *testing.T) { - mgr := profiles.NewManager() - p := &noCompatProfile{} - provider := profiles.ProviderInfo{ - ID: "any", - Name: "Any", - Compatibility: map[string]bool{}, - } - - backends := mgr.BackendsForProvider(p, provider) - if len(backends) != len(p.SupportedBackends()) { - t.Errorf("expected all backends for non-CompatChecker profile, got %d want %d", - len(backends), len(p.SupportedBackends())) - } -} - -func TestLauncher_DedupBackends(t *testing.T) { - mgr := profiles.NewManager() - p := &profiles.ClaudeCodeProfile{} - provider := profiles.ProviderInfo{ - ID: "multi", - Name: "Multi", - Compatibility: map[string]bool{ - "anthropic_messages": true, - "bedrock_model_invoke": true, - }, - } - - backends := mgr.BackendsForProvider(p, provider) - // Before dedup: Anthropic + ZAI (both anthropic_messages) + Bedrock = 3 - if len(backends) != 3 { - t.Fatalf("expected 3 backends before dedup, got %d", len(backends)) - } - - deduped := mgr.DedupBackends(p, backends) - // After dedup: Anthropic (first of the anthropic_messages group) + Bedrock = 2 - if len(deduped) != 2 { - t.Fatalf("expected 2 backends after dedup, got %d", len(deduped)) - } - - gotTypes := make(map[profiles.BackendType]bool) - for _, b := range deduped { - gotTypes[b.Type] = true - } - if !gotTypes[profiles.BackendAnthropic] || !gotTypes[profiles.BackendBedrock] { - t.Errorf("expected Anthropic and Bedrock after dedup, got: %v", deduped) - } - // ZAI should have been deduped away (same compat key as Anthropic). - if gotTypes[profiles.BackendZAI] { - t.Error("ZAI should have been deduplicated since it shares anthropic_messages with Anthropic") - } -} - -func TestLauncher_DedupBackends_NoCompatChecker(t *testing.T) { - mgr := profiles.NewManager() - p := &noCompatProfile{} - backends := p.SupportedBackends() - - deduped := mgr.DedupBackends(p, backends) - if len(deduped) != len(backends) { - t.Errorf("expected no dedup for non-CompatChecker profile, got %d want %d", - len(deduped), len(backends)) - } -} - -func TestLauncher_ClaudeCode_ApplyModel(t *testing.T) { - p := &profiles.ClaudeCodeProfile{} - env := map[string]string{"ANTHROPIC_BASE_URL": "http://ai"} - p.ApplyModel("claude-sonnet-4-20250514", env) - if env["ANTHROPIC_MODEL"] != "claude-sonnet-4-20250514" { - t.Errorf("ANTHROPIC_MODEL = %q, want %q", env["ANTHROPIC_MODEL"], "claude-sonnet-4-20250514") - } -} - -func TestLauncher_StateFile_LastProviderID_RoundTrip(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg == "" { - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) - } - - want := profiles.StateFile{ - LastProfileName: "Claude Code", - LastBackendType: string(profiles.BackendAnthropic), - LastProviderID: "anthropic-via-aperture", - LastModel: "anthropic-via-aperture/claude-sonnet", - } - if err := profiles.SaveState(want); err != nil { - t.Fatalf("SaveState: %v", err) - } - - got, err := profiles.LoadState() - if err != nil { - t.Fatalf("LoadState: %v", err) - } - - if got.LastProviderID != want.LastProviderID { - t.Errorf("LastProviderID = %q, want %q", got.LastProviderID, want.LastProviderID) - } - if got.LastModel != want.LastModel { - t.Errorf("LastModel = %q, want %q", got.LastModel, want.LastModel) - } -} - -// noCompatProfile is a test Profile that does not implement CompatChecker. -type noCompatProfile struct{} - -func (noCompatProfile) Name() string { return "no-compat" } -func (noCompatProfile) BinaryName() string { return "no-compat-binary" } -func (noCompatProfile) SupportedBackends() []profiles.Backend { - return []profiles.Backend{ - {Type: profiles.BackendAnthropic, DisplayName: "Anthropic"}, - {Type: profiles.BackendOpenAI, DisplayName: "OpenAI"}, - } -} -func (noCompatProfile) Env(string, profiles.Backend) (map[string]string, error) { - return nil, nil -} - -func TestLauncher_AllProfiles_ImplementPathHinter(t *testing.T) { - mgr := profiles.NewManager() - for _, p := range mgr.AllProfiles() { - if _, ok := p.(profiles.PathHinter); !ok { - t.Errorf("profile %q does not implement PathHinter", p.Name()) - } - } -} diff --git a/internal/tui/menus.go b/internal/tui/menus.go new file mode 100644 index 0000000..6f6e147 --- /dev/null +++ b/internal/tui/menus.go @@ -0,0 +1,345 @@ +package tui + +import ( + "fmt" + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/menu" +) + +const ( + rootTitle = "Which editor do you want to use?" + endpointsTitle = "Aperture Endpoints" +) + +// rootMenu is the top-level client picker. It shows installed clients in +// registration order and prepends a [0] quick-select row when any client's +// Replay() is ready to re-launch the last session. +func (m *model) rootMenu() *menu.Menu { + all := registeredClients(m.g) + var installed []clients.Client + var uninstalled []clients.Client + for _, c := range all { + if c.IsInstalled() { + installed = append(installed, c) + } else { + uninstalled = append(uninstalled, c) + } + } + + items := make([]menu.MenuItem, 0, len(installed)+3) + + // [0] quick-select, if a client can replay the last launch. + if cmd, quick := m.quickSelect(); cmd != nil { + items = append(items, menu.MenuItem{ + Digit: menu.DigitZero, + Label: "Quick select: " + quick, + Action: func() menu.Result { return menu.Result{Cmd: cmd, PopOnDone: true} }, + }) + } + + for _, c := range installed { + it := c.Menu(m.g) + if it.Action == nil { + continue + } + items = append(items, it) + } + + hints := []string{"[s] Settings"} + if len(uninstalled) > 0 { + hints = append(hints, "[i] Install agents") + } + hints = append(hints, "[q] Quit") + + // Shortcut-only items (hidden so they don't take a number but are + // activated via their Shortcut key). + items = append(items, menu.MenuItem{ + Label: "Settings", + Shortcut: "s", + Hidden: true, + Action: func() menu.Result { return menu.Result{Next: m.settingsMenu()} }, + }) + if len(uninstalled) > 0 { + items = append(items, menu.MenuItem{ + Label: "Install agents", + Shortcut: "i", + Hidden: true, + Action: func() menu.Result { return menu.Result{Next: m.installAgentsMenu()} }, + }) + } + + return &menu.Menu{ + Title: rootTitle, + Items: items, + Hint: strings.Join(hints, " "), + } +} + +// quickSelect returns the tea.Cmd that replays the last successful launch +// and the human-readable label to render next to [0]. Returns nil if no +// client claims the last launch or its state is stale. +func (m *model) quickSelect() (tea.Cmd, string) { + if m.g.LastLaunch.LastClientName == "" { + return nil, "" + } + for _, c := range registeredClients(m.g) { + cmd := c.Replay(m.g) + if cmd != nil { + return cmd, c.QuickSelectLabel(m.g) + } + } + return nil, "" +} + +// settingsMenu is the top-level Settings page: endpoints, uninstall, YOLO toggle. +func (m *model) settingsMenu() *menu.Menu { + yolo := "off" + if m.g.Settings.YoloMode { + yolo = "on" + } + return &menu.Menu{ + Title: "Settings", + Items: []menu.MenuItem{ + { + Label: "Aperture Endpoints", + Action: func() menu.Result { return menu.Result{Next: m.endpointsMenu()} }, + }, + { + Label: "Uninstall", + Action: func() menu.Result { return menu.Result{Next: m.uninstallMenu()} }, + }, + { + Label: "YOLO mode: " + yolo, + Action: func() menu.Result { + _ = m.g.SetYolo(!m.g.Settings.YoloMode) + return menu.Result{Replace: m.settingsMenu()} + }, + }, + }, + Hint: "Enter to select · Esc to go back", + } +} + +// endpointsMenu lists configured endpoints with add/delete affordances. +// Selecting an entry rotates it to the front and re-runs preflight. +func (m *model) endpointsMenu() *menu.Menu { + items := make([]menu.MenuItem, 0, len(m.g.Settings.Endpoints)+3) + for i, ep := range m.g.Settings.Endpoints { + url := ep.URL + label := url + if i == 0 { + label = greenStyle.Render(url + " (active)") + } + items = append(items, menu.MenuItem{ + Label: label, + Action: func() menu.Result { + if err := m.g.SetApertureHost(url); err != nil { + return errResult(err.Error()) + } + m.step = stepPreflight + return menu.Result{Cmd: runPreflight(url)} + }, + }) + } + items = append(items, menu.MenuItem{ + Label: "Add endpoint", + Shortcut: "a", + Action: func() menu.Result { + m.promptForInput("Add Endpoint:", "", func(v string) tea.Cmd { + _ = m.g.UpsertEndpoint(v) + if len(m.stack) > 0 { + m.stack[len(m.stack)-1] = m.endpointsMenu() + m.cursors[len(m.cursors)-1] = 0 + } + return nil + }) + return menu.Result{} + }, + }) + // Hidden: "d" deletes the row under the cursor. + items = append(items, menu.MenuItem{ + Label: "delete", + Shortcut: "d", + Hidden: true, + Action: func() menu.Result { + idx := m.cursor() + if idx < 0 || idx >= len(m.g.Settings.Endpoints) || len(m.g.Settings.Endpoints) <= 1 { + return menu.Result{} + } + _ = m.g.RemoveEndpoint(idx) + return menu.Result{Replace: m.endpointsMenu()} + }, + }) + + backHint := "Esc to go back" + if m.forcedToEndpoint { + backHint = "Esc to quit" + } + + return &menu.Menu{ + Title: endpointsTitle, + Items: items, + Hint: "Enter to select · d to remove · a to add · " + backHint, + OnBack: func() tea.Cmd { + if m.forcedToEndpoint { + return tea.Quit + } + m.popOne() + return tea.ClearScreen + }, + } +} + +// installAgentsMenu lists uninstalled clients and confirms/runs each install. +func (m *model) installAgentsMenu() *menu.Menu { + var items []menu.MenuItem + for _, c := range registeredClients(m.g) { + if c.IsInstalled() { + continue + } + c := c + items = append(items, menu.MenuItem{ + Label: c.Name(), + Action: func() menu.Result { return menu.Result{Next: m.installConfirmMenu(c)} }, + }) + } + if len(items) == 0 { + return &menu.Menu{ + Title: "Install agents", + Items: []menu.MenuItem{{Label: "All agents installed.", Disabled: true}}, + Hint: "Esc to go back", + } + } + return &menu.Menu{ + Title: "Install agents", + Items: items, + Hint: "Enter to select · Esc to go back", + } +} + +func (m *model) installConfirmMenu(c clients.Client) *menu.Menu { + plan := c.Install(m.g) + return &menu.Menu{ + Title: "Install " + c.Name() + "?", + Items: []menu.MenuItem{ + {Label: plan.Hint, Disabled: true}, + { + Label: "Install", + Shortcut: "y", + Action: func() menu.Result { + if plan.Run == nil { + return menu.Result{Pop: true} + } + return menu.Result{Cmd: runInstallCmd(plan.Run), PopOnDone: true} + }, + }, + { + Label: "Cancel", + Shortcut: "n", + Action: func() menu.Result { return menu.Result{Pop: true} }, + }, + }, + Hint: "y to install · n to cancel", + } +} + +// uninstallMenu lists installed clients and confirms/runs uninstall. +func (m *model) uninstallMenu() *menu.Menu { + var items []menu.MenuItem + for _, c := range registeredClients(m.g) { + if !c.IsInstalled() { + continue + } + c := c + items = append(items, menu.MenuItem{ + Label: c.Name(), + Action: func() menu.Result { return menu.Result{Next: m.uninstallConfirmMenu(c)} }, + }) + } + if len(items) == 0 { + return &menu.Menu{ + Title: "Uninstall", + Items: []menu.MenuItem{{Label: "No agents installed.", Disabled: true}}, + Hint: "Esc to go back", + } + } + return &menu.Menu{ + Title: "Uninstall", + Items: items, + Hint: "Enter to select · Esc to go back", + } +} + +func (m *model) uninstallConfirmMenu(c clients.Client) *menu.Menu { + plan := c.Uninstall() + if plan.Run == nil { + return &menu.Menu{ + Title: c.Name(), + Items: []menu.MenuItem{ + {Label: plan.Hint, Disabled: true}, + {Label: "OK", Shortcut: "y", Action: func() menu.Result { return menu.Result{Pop: true} }}, + }, + Hint: "Enter to go back", + } + } + return &menu.Menu{ + Title: "Uninstall " + c.Name() + "?", + Items: []menu.MenuItem{ + {Label: "This will run: " + plan.Hint, Disabled: true}, + { + Label: "Uninstall", + Shortcut: "y", + Action: func() menu.Result { + return menu.Result{Cmd: runUninstallFn(plan.Run)} + }, + }, + { + Label: "Cancel", + Shortcut: "n", + Action: func() menu.Result { return menu.Result{Pop: true} }, + }, + }, + Hint: "y to uninstall · n to cancel", + } +} + +// runInstallCmd returns a tea.Cmd that runs the provided install command +// with terminal takeover (so the user sees download progress) and emits +// menu.InstallDoneMsg on completion. +func runInstallCmd(producer func() (*exec.Cmd, error)) tea.Cmd { + cmd, err := producer() + if err != nil { + return func() tea.Msg { return menu.InstallDoneMsg{Err: err} } + } + if cmd == nil { + return func() tea.Msg { return menu.InstallDoneMsg{} } + } + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return tea.ExecProcess(cmd, func(err error) tea.Msg { + return menu.InstallDoneMsg{Err: err} + }) +} + +// runUninstallFn returns a tea.Cmd that invokes the uninstall function and +// emits menu.InstallDoneMsg (we reuse the install-done flow to re-scan the +// client list on completion). +func runUninstallFn(run func() error) tea.Cmd { + return func() tea.Msg { + return menu.InstallDoneMsg{Err: run()} + } +} + +// errResult is a small helper to emit an error through the shared done-msg +// channel from a menu builder. +func errResult(msg string) menu.Result { + return menu.Result{Cmd: func() tea.Msg { + return menu.SimpleDoneMsg{Err: fmt.Errorf("%s", msg)} + }} +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 618053b..a1f5f4c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,3 +1,9 @@ +// Package tui is the bubbletea-driven interactive launcher. It renders a +// generic navigable menu stack described by internal/menu; each entry on +// the stack comes from either the root client picker (built from +// internal/clients) or a sub-menu pushed by a client's action closure. +// The TUI owns only the preflight HTTP check, a single-line text input +// step, and error screens — everything else is expressed as Menu values. package tui import ( @@ -5,34 +11,23 @@ import ( "fmt" "io" "net/http" - "os" - "os/exec" - "strconv" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/tailscale/aperture-cli/internal/profiles" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/menu" ) type step int const ( - stepPreflight step = iota // checking /api/providers - stepSelectProfile // choose profile - stepSelectProvider // choose provider for the selected profile - stepSelectBackend // choose backend (only when genuinely different compat keys) - stepSelectModel // choose default model from provider's model list - stepSettings // top-level settings menu - stepEndpoints // manage aperture endpoints - stepAddLocation // type a new endpoint URL - stepInstall // show install hint for an uninstalled profile - stepInstallAgents // choose an uninstalled profile to install - stepUninstall // list installed profiles to uninstall - stepUninstallConfirm // confirm uninstall for a chosen profile - stepCheckError // pre-launch validation failure - stepError // fatal error + stepPreflight step = iota + stepMenu // rendering the top of the stack + stepInput // single-line text input (add-endpoint) + stepError // fatal/fixable error message ) var ( @@ -47,103 +42,54 @@ var ( dotRed = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("●") ) -// preflightResult carries the outcome of the /api/providers check. -type preflightResult struct { - host string - providers []profiles.ProviderInfo - err error -} - -type resolvedSelection struct { - combo profiles.Combo - provider profiles.ProviderInfo - selectedModel string +// NewModel returns the TUI model. g holds the persisted launcher state +// (settings, endpoints, last launch). buildVersion is shown at the bottom +// of the client picker. +func NewModel(g *config.Global, buildVersion string) tea.Model { + return &model{ + g: g, + buildVersion: buildVersion, + step: stepPreflight, + } } type model struct { - apertureHost string - settings profiles.Settings - state profiles.StateFile - manager *profiles.Manager - - // resolved selection for the last-used shortcut - lastSelection *resolvedSelection - - // all known profiles; installedProfiles is the subset on PATH - allProfiles []profiles.Profile - installedProfiles []profiles.Profile - - step step - profileCursor int - backendItems []profiles.Backend - backendCursor int - - chosenProfile profiles.Profile - chosenProvider profiles.ProviderInfo - chosenBackend profiles.Backend - - // provider selection step - providerItems []profiles.ProviderInfo - providerCursor int - - // model selection step - modelItems []string - modelCursor int - selectedModel string - - // preflight state - preflightChecking bool - providers []profiles.ProviderInfo - preflightErr string - - // endpointsFromSetup is true when stepEndpoints was reached via preflight failure. - endpointsFromSetup bool - - // settings step - settingsCursor int + g *config.Global + buildVersion string - // endpoints submenu step - endpointsCursor int + step step - // install agents submenu step - installAgentsCursor int + // Menu stack. The top (last element) is what's rendered and receives key + // input during stepMenu. + stack []*menu.Menu + // Per-menu cursor positions, one per stack entry. + cursors []int - // uninstall submenu step - uninstallCursor int + // Input step state. + inputTitle string + inputPrompt string + inputValue string + inputOnSave func(value string) tea.Cmd - // add-location step - addLocationInput string + // Error screen state. + errMsg string - buildVersion string - debug bool - err string + // Preflight state. + preflightErr string + forcedToEndpoint bool // true when preflight failure dropped user on endpoints menu } -// NewModel constructs the TUI model. It satisfies tea.Model. -func NewModel(apertureHost string, settings profiles.Settings, state profiles.StateFile, buildVersion string, debug bool) tea.Model { - mgr := profiles.NewManager() - - m := model{ - apertureHost: apertureHost, - settings: settings, - state: state, - manager: mgr, - allProfiles: mgr.AllProfiles(), - installedProfiles: mgr.InstalledProfiles(), - buildVersion: buildVersion, - debug: debug, - step: stepPreflight, - preflightChecking: true, - } - - return m +func (m *model) Init() tea.Cmd { + return runPreflight(m.g.ApertureHost) } -func (m model) Init() tea.Cmd { - return runPreflight(m.apertureHost) +// preflightResult is emitted when the /api/providers check completes. +type preflightResult struct { + host string + providers []config.ProviderInfo + err error } -// runPreflight performs the GET {host}/api/providers check asynchronously. func runPreflight(host string) tea.Cmd { return func() tea.Msg { client := &http.Client{Timeout: 10 * time.Second} @@ -153,110 +99,72 @@ func runPreflight(host string) tea.Cmd { return preflightResult{host: host, err: err} } defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { return preflightResult{ host: host, err: fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url), } } - body, err := io.ReadAll(resp.Body) if err != nil { return preflightResult{host: host, err: err} } - - var providers []profiles.ProviderInfo - if err := json.Unmarshal(body, &providers); err != nil { + var provs []config.ProviderInfo + if err := json.Unmarshal(body, &provs); err != nil { return preflightResult{host: host, err: fmt.Errorf("could not parse providers response: %w", err)} } - return preflightResult{host: host, providers: providers} + return preflightResult{host: host, providers: provs} } } -type autoSelectMsg struct{ selection resolvedSelection } -type execDoneMsg struct{ err error } -type launchDoneMsg struct{ err error } -type installDoneMsg struct{ err error } -type uninstallDoneMsg struct{ err error } - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case preflightResult: - m.preflightChecking = false if msg.err != nil { m.preflightErr = msg.err.Error() - m.endpointsFromSetup = true - m.step = stepEndpoints - m.endpointsCursor = 0 + m.forcedToEndpoint = true + m.step = stepMenu + m.resetStack(m.endpointsMenu()) return m, nil } - // Success: store providers, update settings with confirmed host, proceed. - m.providers = msg.providers + m.g.Providers = msg.providers m.preflightErr = "" - m.endpointsFromSetup = false - m.settings = upsertLocation(m.settings, m.apertureHost) - _ = profiles.SaveSettings(m.settings) - // Re-check which CLIs are installed now. - m.installedProfiles = m.manager.InstalledProfiles() - m.refreshLastSelection() - m.resetProfileCursor() - // Auto-select only when there's a single unambiguous path through - // profile → provider → backend. - if selection, ok := m.tryAutoSelect(); ok { - return m, func() tea.Msg { return autoSelectMsg{selection: selection} } - } - m.step = stepSelectProfile + m.forcedToEndpoint = false + // Ensure the active host is in the endpoint list and first. + _ = m.g.UpsertEndpoint(m.g.ApertureHost) + m.step = stepMenu + m.resetStack(m.rootMenu()) return m, tea.ClearScreen - case autoSelectMsg: - return m, m.execSelection(msg.selection) + case menu.ExecDoneMsg: + // A client's foreground launch has exited. Re-run preflight: the + // user may have changed things outside the launcher while the + // agent was running. + m.popToRoot() + m.step = stepPreflight + return m, runPreflight(m.g.ApertureHost) - case installDoneMsg: - // Re-check installed CLIs after the install command finishes. - m.installedProfiles = m.manager.InstalledProfiles() - m.step = stepSelectProfile - m.refreshLastSelection() - m.resetProfileCursor() + case menu.InstallDoneMsg: + // Rebuild the root menu so install state is reflected. + m.step = stepMenu + m.resetStack(m.rootMenu()) return m, tea.ClearScreen - case uninstallDoneMsg: - // Re-check installed CLIs after the uninstall command finishes. - m.installedProfiles = m.manager.InstalledProfiles() - m.step = stepUninstall - m.uninstallCursor = 0 - return m, nil - - case execDoneMsg: - // Reload state from disk to reflect the last-used profile that just exited. - state, _ := profiles.LoadState() - m.state = state - m.lastSelection = nil - // Re-check installed CLIs in case something changed while the agent ran. - m.installedProfiles = m.manager.InstalledProfiles() - - // Re-run preflight after agent exits. - m.step = stepPreflight - m.preflightChecking = true - m.profileCursor = 0 - return m, runPreflight(m.apertureHost) + case menu.LaunchDoneMsg: + // Desktop-style launch returned immediately; stay on root menu. + m.popToRoot() + m.step = stepMenu + m.resetStack(m.rootMenu()) + return m, tea.ClearScreen - case launchDoneMsg: - // Desktop app launched (returns immediately). Go back to the profile - // selection screen without re-running preflight to avoid an - // auto-select loop. - if msg.err != nil { - m.err = msg.err.Error() + case menu.SimpleDoneMsg: + if msg.Err != nil { + m.errMsg = msg.Err.Error() m.step = stepError return m, nil } - state, _ := profiles.LoadState() - m.state = state - m.installedProfiles = m.manager.InstalledProfiles() - m.refreshLastSelection() - m.step = stepSelectProfile - m.resetProfileCursor() - return m, tea.ClearScreen + m.popOne() + return m, nil case tea.KeyMsg: switch m.step { @@ -264,1243 +172,381 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { return m, tea.Quit } - + return m, nil case stepError: - return m, tea.Quit - - case stepSelectProfile: - return m.updateSelectProfile(msg) - - case stepSelectProvider: - return m.updateSelectProvider(msg) - - case stepSelectBackend: - return m.updateSelectBackend(msg) - - case stepSelectModel: - return m.updateSelectModel(msg) - - case stepSettings: - return m.updateSettings(msg) - - case stepEndpoints: - return m.updateEndpoints(msg) - - case stepAddLocation: - return m.updateAddLocation(msg) - - case stepInstall: - return m.updateInstall(msg) - - case stepInstallAgents: - return m.updateInstallAgents(msg) - - case stepUninstall: - return m.updateUninstall(msg) - - case stepUninstallConfirm: - return m.updateUninstallConfirm(msg) - - case stepCheckError: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit - case "esc": - m.step = stepSelectProvider + default: + m.step = stepMenu + return m, nil } + case stepInput: + return m.updateInput(msg) + case stepMenu: + return m.updateMenu(msg) } } return m, nil } -// tryAutoSelect returns a resolved selection and true when there is exactly one installed -// profile, one compatible provider, and one deduped backend for that provider, -// and either the provider has 0-1 models or the profile doesn't support model -// selection. This is the only case where we skip the menu entirely. -func (m model) tryAutoSelect() (resolvedSelection, bool) { - if len(m.installedProfiles) != 1 { - return resolvedSelection{}, false - } - p := m.installedProfiles[0] - providers := m.manager.CompatibleProviders(p, m.providers) - if len(providers) != 1 { - return resolvedSelection{}, false - } - backends := m.manager.BackendsForProvider(p, providers[0]) - backends = m.manager.DedupBackends(p, backends) - if len(backends) != 1 { - return resolvedSelection{}, false - } - // If the profile supports model selection and the provider has multiple - // models, don't auto-select — the user needs to pick a model. - if wantsModelSelection(p, backends[0]) && len(providers[0].Models) > 1 { - return resolvedSelection{}, false - } - selectedModel := "" - if wantsModelSelection(p, backends[0]) && len(providers[0].Models) == 1 { - selectedModel = providers[0].ID + "/" + providers[0].Models[0] - } - return resolvedSelection{ - combo: profiles.Combo{Profile: p, Backend: backends[0]}, - provider: providers[0], - selectedModel: selectedModel, - }, true -} - -// isInstalled reports whether a profile's binary is currently on PATH, -// using the cached installedProfiles slice. -func (m model) isInstalled(p profiles.Profile) bool { - for _, ip := range m.installedProfiles { - if ip.Name() == p.Name() { - return true - } - } - return false -} - -func (m *model) resetProfileCursor() { - if m.lastSelection != nil { - m.profileCursor = -1 - return - } - m.profileCursor = 0 -} - -// uninstalledProfiles returns profiles that are not currently installed. -func (m model) uninstalledProfiles() []profiles.Profile { - var result []profiles.Profile - for _, p := range m.allProfiles { - if !m.isInstalled(p) { - result = append(result, p) - } - } - return result -} - -func (m model) updateSelectProfile(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - profileCount := len(m.installedProfiles) - minCursor := 0 - if m.lastSelection != nil { - minCursor = -1 +func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + top := m.top() + if top == nil { + return m, nil } + cursor := m.cursor() switch msg.String() { - case "ctrl+c", "q": + case "ctrl+c": return m, tea.Quit - case "s": - m.step = stepSettings - m.settingsCursor = 0 - return m, nil - - case "i": - if len(m.uninstalledProfiles()) > 0 { - m.step = stepInstallAgents - m.installAgentsCursor = 0 - return m, nil - } - - case "up", "k": - if m.profileCursor > minCursor { - m.profileCursor-- - } - - case "down", "j": - if m.profileCursor < profileCount-1 { - m.profileCursor++ - } - - case "enter": - if m.profileCursor == -1 && m.lastSelection != nil { - return m, m.execSelection(*m.lastSelection) - } - return m.confirmProfileSelection() - - default: - n, err := strconv.Atoi(msg.String()) - if err == nil { - // [0] launches the last-used profile/provider/model selection. - if n == 0 && m.lastSelection != nil { - return m, m.execSelection(*m.lastSelection) - } - // [1..N] selects a profile directly. - idx := n - 1 - if idx >= 0 && idx < profileCount { - m.profileCursor = idx - return m.confirmProfileSelection() - } + case "q": + // "q" quits from the root only; on sub-menus it pops. + if len(m.stack) <= 1 { + return m, tea.Quit } - } - return m, nil -} - -// confirmProfileSelection resolves the chosen profile and transitions to -// provider selection or auto-launches if only one provider is compatible. -func (m model) confirmProfileSelection() (model, tea.Cmd) { - if m.profileCursor < 0 || m.profileCursor >= len(m.installedProfiles) { - return m, nil - } - chosen := m.installedProfiles[m.profileCursor] - m.chosenProfile = chosen - m.selectedModel = "" - - m.providerItems = m.manager.CompatibleProviders(chosen, m.providers) - if len(m.providerItems) == 0 { - m.err = fmt.Sprintf("No compatible providers for %s.", chosen.Name()) - m.step = stepCheckError - return m, nil - } - if len(m.providerItems) == 1 { - m.chosenProvider = m.providerItems[0] - return m.resolveProviderAndExec() - } - m.providerCursor = 0 - m.step = stepSelectProvider - return m, nil -} + m.popOne() + return m, tea.ClearScreen -func (m model) updateSelectProvider(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit case "esc": - m.step = stepSelectProfile - return m, tea.ClearScreen - case "up", "k": - if m.providerCursor > 0 { - m.providerCursor-- - } - case "down", "j": - if m.providerCursor < len(m.providerItems)-1 { - m.providerCursor++ - } - case "enter": - return m.confirmProviderSelection() - default: - n, err := strconv.Atoi(msg.String()) - if err == nil { - idx := n - 1 - if idx >= 0 && idx < len(m.providerItems) { - m.providerCursor = idx - return m.confirmProviderSelection() + if top.OnBack != nil { + if cmd := top.OnBack(); cmd != nil { + return m, cmd } + return m, nil } - } - return m, nil -} - -// confirmProviderSelection resolves the chosen provider and either auto-launches -// (single backend) or shows the backend submenu. -func (m model) confirmProviderSelection() (model, tea.Cmd) { - if m.providerCursor < 0 || m.providerCursor >= len(m.providerItems) { - return m, nil - } - m.chosenProvider = m.providerItems[m.providerCursor] - return m.resolveProviderAndExec() -} - -// resolveProviderAndExec checks how many backends the chosen provider supports -// for the chosen profile, then either auto-launches, shows a model picker, or -// shows the backend submenu. -func (m model) resolveProviderAndExec() (model, tea.Cmd) { - backends := m.manager.BackendsForProvider(m.chosenProfile, m.chosenProvider) - if len(backends) == 0 { - m.err = fmt.Sprintf("No compatible backends for %s with %s.", - m.chosenProfile.Name(), m.chosenProvider.DisplayName()) - m.step = stepCheckError - return m, nil - } - - // Deduplicate backends that share the same compat key signature - // (e.g. Anthropic and ZAI both use "anthropic_messages"). - backends = m.manager.DedupBackends(m.chosenProfile, backends) - - if len(backends) == 1 { - return m.proceedWithBackend(backends[0]) - } - // Multiple genuinely-different backends (e.g. Anthropic vs Bedrock). - m.backendItems = backends - m.backendCursor = 0 - m.step = stepSelectBackend - return m, nil -} - -// proceedWithBackend resolves the model selection for a single backend and -// either auto-launches or shows the model picker. -func (m model) proceedWithBackend(b profiles.Backend) (model, tea.Cmd) { - wantsModel := wantsModelSelection(m.chosenProfile, b) - - if wantsModel && len(m.chosenProvider.Models) > 1 { - m.chosenBackend = b - m.modelItems = fqnModels(m.chosenProvider) - m.modelCursor = 0 - m.step = stepSelectModel - return m, nil - } - - // Auto-select the single model if available. - if wantsModel && len(m.chosenProvider.Models) == 1 { - m.selectedModel = m.chosenProvider.ID + "/" + m.chosenProvider.Models[0] - } - - if checker, ok := m.chosenProfile.(profiles.Checker); ok { - if err := checker.Check(b); err != nil { - m.err = err.Error() - m.step = stepCheckError + if len(m.stack) <= 1 { + // Root menu ignores Esc. return m, nil } - } - combo := profiles.Combo{Profile: m.chosenProfile, Backend: b} - return m, m.execCombo(combo) -} - -func (m model) updateInstall(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "esc", "n", "enter": - m.step = stepSelectProfile + m.popOne() return m, tea.ClearScreen - case "y": - return m, m.runInstall() - } - return m, nil -} - -func (m model) updateInstallAgents(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - uninstalled := m.uninstalledProfiles() - total := len(uninstalled) - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "esc": - m.step = stepSelectProfile - return m, tea.ClearScreen case "up", "k": - if m.installAgentsCursor > 0 { - m.installAgentsCursor-- - } - case "down", "j": - if m.installAgentsCursor < total-1 { - m.installAgentsCursor++ - } - case "enter": - if m.installAgentsCursor < total { - m.chosenProfile = uninstalled[m.installAgentsCursor] - m.step = stepInstall + if cursor > 0 { + m.setCursor(cursor - 1) + m.skipHiddenUp() } return m, nil - default: - n, err := strconv.Atoi(msg.String()) - if err == nil { - idx := n - 1 - if idx >= 0 && idx < total { - m.installAgentsCursor = idx - m.chosenProfile = uninstalled[idx] - m.step = stepInstall - } - } - } - return m, nil -} - -func (m model) runInstall() tea.Cmd { - // Host-aware installers write platform config and return a download command. - if hai, ok := m.chosenProfile.(profiles.HostAwareInstaller); ok { - cmd, err := hai.RunInstall(m.apertureHost) - if err != nil { - return func() tea.Msg { return installDoneMsg{err: err} } - } - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return tea.ExecProcess(cmd, func(err error) tea.Msg { - return installDoneMsg{err: err} - }) - } - - inst, ok := m.chosenProfile.(profiles.Installer) - if !ok { - return nil - } - cmd := exec.Command("/bin/sh", "-c", inst.InstallHint()) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return tea.ExecProcess(cmd, func(err error) tea.Msg { - return installDoneMsg{err: err} - }) -} -func (m model) updateUninstall(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - total := len(m.installedProfiles) - - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "esc": - m.step = stepSettings - return m, nil - case "up", "k": - if m.uninstallCursor > 0 { - m.uninstallCursor-- - } case "down", "j": - if m.uninstallCursor < total-1 { - m.uninstallCursor++ - } - case "enter": - if m.uninstallCursor < len(m.installedProfiles) { - m.chosenProfile = m.installedProfiles[m.uninstallCursor] - m.step = stepUninstallConfirm - } - default: - n, err := strconv.Atoi(msg.String()) - if err == nil { - idx := n - 1 - if idx >= 0 && idx < len(m.installedProfiles) { - m.uninstallCursor = idx - m.chosenProfile = m.installedProfiles[idx] - m.step = stepUninstallConfirm - } - } - } - return m, nil -} - -func (m model) updateUninstallConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "esc", "n", "enter": - m.step = stepUninstall - case "y": - return m, m.runUninstall() - } - return m, nil -} - -func (m model) runUninstall() tea.Cmd { - uninst, ok := m.chosenProfile.(profiles.Uninstaller) - if !ok { - return nil - } - fn := uninst.Uninstall() - return func() tea.Msg { - return uninstallDoneMsg{err: fn()} - } -} - -func (m model) updateSelectBackend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "esc": - m.step = stepSelectProvider - return m, tea.ClearScreen - - case "up", "k": - if m.backendCursor > 0 { - m.backendCursor-- - } - case "down", "j": - if m.backendCursor < len(m.backendItems)-1 { - m.backendCursor++ + if cursor < len(top.Items)-1 { + m.setCursor(cursor + 1) + m.skipHiddenDown() } + return m, nil case "enter": - return m.checkAndExecSelectedBackend() + return m.activate(cursor) default: - n, err := strconv.Atoi(msg.String()) - if err == nil { - idx := n - 1 - if idx >= 0 && idx < len(m.backendItems) { - m.backendCursor = idx - return m.checkAndExecSelectedBackend() + s := msg.String() + // Digit shortcut: activate item with matching Digit. Auto-numbered + // items count in visible order, skipping any item with an explicit + // Digit assignment. + if len(s) == 1 && s[0] >= '0' && s[0] <= '9' { + auto := 1 + for i, it := range top.Items { + if it.Hidden || it.Disabled { + continue + } + var d int + switch it.Digit { + case 0: + d = auto + auto++ + case menu.DigitZero: + d = 0 + default: + d = it.Digit + } + if s[0]-'0' == byte(d) { + return m.activate(i) + } } } - } - return m, nil -} - -func (m model) updateSelectModel(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "esc": - m.step = stepSelectProvider - return m, tea.ClearScreen - case "up", "k": - if m.modelCursor > 0 { - m.modelCursor-- - } - case "down", "j": - if m.modelCursor < len(m.modelItems)-1 { - m.modelCursor++ - } - case "enter": - return m.confirmModelSelection() - default: - n, err := strconv.Atoi(msg.String()) - if err == nil { - idx := n - 1 - if idx >= 0 && idx < len(m.modelItems) { - m.modelCursor = idx - return m.confirmModelSelection() + // Single-char shortcut. + if len(s) == 1 { + for i, it := range top.Items { + if it.Hidden || it.Disabled { + continue + } + if it.Shortcut != "" && it.Shortcut == s { + return m.activate(i) + } } } } return m, nil } -func (m model) confirmModelSelection() (model, tea.Cmd) { - if m.modelCursor < 0 || m.modelCursor >= len(m.modelItems) { +func (m *model) activate(idx int) (tea.Model, tea.Cmd) { + top := m.top() + if top == nil || idx < 0 || idx >= len(top.Items) { return m, nil } - m.selectedModel = m.modelItems[m.modelCursor] - - b := m.chosenBackend - if checker, ok := m.chosenProfile.(profiles.Checker); ok { - if err := checker.Check(b); err != nil { - m.err = err.Error() - m.step = stepCheckError - return m, nil - } - } - combo := profiles.Combo{Profile: m.chosenProfile, Backend: b} - return m, m.execCombo(combo) -} - -// settingsRows returns the rows for the top-level settings menu. -// Row layout: "Aperture Endpoints" + "Uninstall" + "YOLO mode". -func (m model) settingsRows() []string { - yoloLabel := "YOLO mode: off" - if m.settings.YoloMode { - yoloLabel = "YOLO mode: on" - } - return []string{"Aperture Endpoints", "Uninstall", yoloLabel} -} - -// settingsYoloIdx returns the cursor index of the YOLO mode row. -func (m model) settingsYoloIdx() int { return 2 } - -func (m model) updateSettings(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - rows := m.settingsRows() - total := len(rows) - - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - - case "esc", "q": - m.step = stepSelectProfile - return m, tea.ClearScreen - - case "up", "k": - if m.settingsCursor > 0 { - m.settingsCursor-- - } - - case "down", "j": - if m.settingsCursor < total-1 { - m.settingsCursor++ - } - - case "enter": - return m.confirmSettingsSelection() - - default: - n, err := strconv.Atoi(msg.String()) - if err == nil { - idx := n - 1 - if idx >= 0 && idx < total { - m.settingsCursor = idx - return m.confirmSettingsSelection() - } - } - } - return m, nil -} - -func (m model) confirmSettingsSelection() (model, tea.Cmd) { - switch m.settingsCursor { - case 0: // Aperture Endpoints - m.step = stepEndpoints - m.endpointsCursor = 0 - return m, nil - case 1: // Uninstall - m.step = stepUninstall - m.uninstallCursor = 0 - return m, nil - case m.settingsYoloIdx(): - m.settings.YoloMode = !m.settings.YoloMode - _ = profiles.SaveSettings(m.settings) + item := top.Items[idx] + if item.Disabled || item.Action == nil { return m, nil } - return m, nil -} - -// endpointsRows returns the display rows for configured endpoints. -func (m model) endpointsRows() []string { - rows := make([]string, 0, len(m.settings.Endpoints)) - for i, ep := range m.settings.Endpoints { - label := ep.URL - if i == 0 { - label += " (active)" - } - rows = append(rows, label) - } - return rows + m.setCursor(idx) + res := item.Action() + return m.applyResult(res) } -func (m model) updateEndpoints(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - total := len(m.settings.Endpoints) - - switch msg.String() { - case "ctrl+c": +func (m *model) applyResult(res menu.Result) (tea.Model, tea.Cmd) { + switch { + case res.Quit: return m, tea.Quit - - case "esc", "q": - if m.endpointsFromSetup { - return m, tea.Quit - } - m.step = stepSettings - return m, nil - - case "a": - m.step = stepAddLocation - m.addLocationInput = "" - return m, nil - - case "up", "k": - if m.endpointsCursor > 0 { - m.endpointsCursor-- - } - - case "down", "j": - if m.endpointsCursor < total-1 { - m.endpointsCursor++ - } - - case "enter": - return m.confirmEndpointsSelection() - - case "d", "delete": - if m.endpointsCursor < total && total > 1 { - eps := make([]profiles.Endpoint, 0, total-1) - eps = append(eps, m.settings.Endpoints[:m.endpointsCursor]...) - eps = append(eps, m.settings.Endpoints[m.endpointsCursor+1:]...) - m.settings.Endpoints = eps - _ = profiles.SaveSettings(m.settings) - if m.endpointsCursor >= len(m.settings.Endpoints) { - m.endpointsCursor = len(m.settings.Endpoints) - 1 - } - if m.apertureHost != m.settings.Endpoints[0].URL { - m.apertureHost = m.settings.Endpoints[0].URL - } - } - - default: - n, err := strconv.Atoi(msg.String()) - if err == nil { - idx := n - 1 - if idx >= 0 && idx < total { - m.endpointsCursor = idx - return m.confirmEndpointsSelection() - } - } - } - return m, nil -} - -func (m model) confirmEndpointsSelection() (model, tea.Cmd) { - if m.endpointsCursor < len(m.settings.Endpoints) { - selected := m.settings.Endpoints[m.endpointsCursor] - eps := []profiles.Endpoint{selected} - for i, ep := range m.settings.Endpoints { - if i != m.endpointsCursor { - eps = append(eps, ep) - } + case res.Pop: + m.popOne() + return m, tea.ClearScreen + case res.Replace != nil: + if len(m.stack) > 0 { + m.stack[len(m.stack)-1] = res.Replace + m.cursors[len(m.cursors)-1] = 0 + } else { + m.stack = append(m.stack, res.Replace) + m.cursors = append(m.cursors, 0) } - m.settings.Endpoints = eps - _ = profiles.SaveSettings(m.settings) - m.apertureHost = selected.URL - m.step = stepPreflight - m.preflightChecking = true - m.preflightErr = "" - return m, runPreflight(selected.URL) + return m, tea.ClearScreen + case res.Next != nil: + m.stack = append(m.stack, res.Next) + m.cursors = append(m.cursors, 0) + return m, tea.ClearScreen + case res.Cmd != nil: + return m, res.Cmd } return m, nil } -func (m model) updateAddLocation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": - m.step = stepEndpoints + m.step = stepMenu + m.inputValue = "" return m, nil case "enter": - loc := strings.TrimSpace(m.addLocationInput) - if loc == "" { + v := strings.TrimSpace(m.inputValue) + if v == "" { return m, nil } - m.settings = upsertLocation(m.settings, loc) - _ = profiles.SaveSettings(m.settings) - m.step = stepEndpoints - m.endpointsCursor = 0 + fn := m.inputOnSave + m.step = stepMenu + m.inputValue = "" + if fn != nil { + return m, fn(v) + } return m, nil case "backspace": - if len(m.addLocationInput) > 0 { - m.addLocationInput = m.addLocationInput[:len(m.addLocationInput)-1] + if len(m.inputValue) > 0 { + m.inputValue = m.inputValue[:len(m.inputValue)-1] } + return m, nil default: - if len(msg.String()) == 1 { - m.addLocationInput += msg.String() + s := msg.String() + if len(s) == 1 { + m.inputValue += s } + return m, nil } - return m, nil } -// upsertLocation ensures loc is in settings.Endpoints without duplicates. -// If it already exists it stays in place; otherwise it is appended. -// fqnModels returns fully qualified model names in the form "provider_id/model_id". -func fqnModels(p profiles.ProviderInfo) []string { - out := make([]string, len(p.Models)) - for i, m := range p.Models { - out[i] = p.ID + "/" + m +func (m *model) View() string { + switch m.step { + case stepPreflight: + return dotYellow + " Checking " + m.g.ApertureHost + " …\n" + case stepError: + var sb strings.Builder + sb.WriteString(errorStyle.Render("Cannot launch")) + sb.WriteString("\n\n") + sb.WriteString(m.errMsg) + sb.WriteString("\n\n") + sb.WriteString(dimStyle.Render("Any key to go back · q to quit\n")) + return sb.String() + case stepInput: + var sb strings.Builder + sb.WriteString(titleStyle.Render(m.inputTitle)) + sb.WriteString("\n") + if m.inputPrompt != "" { + sb.WriteString(" " + m.inputPrompt + "\n") + } + sb.WriteString(" > " + m.inputValue + "█\n") + sb.WriteString("\n") + sb.WriteString(dimStyle.Render("Enter to save · Esc to cancel\n")) + return sb.String() + case stepMenu: + return m.viewMenu() } - return out + return "" } -// wantsModelSelection reports whether the TUI should show the model picker -// for the given (profile, backend). A profile must implement ModelSelector -// to opt in, and may further opt out per-backend via BackendModelSelector. -func wantsModelSelection(p profiles.Profile, b profiles.Backend) bool { - if _, ok := p.(profiles.ModelSelector); !ok { - return false - } - if g, ok := p.(profiles.BackendModelSelector); ok { - return g.WantsModelSelection(b) +func (m *model) viewMenu() string { + top := m.top() + if top == nil { + return "" } - return true -} - -func containsString(items []string, item string) bool { - for _, v := range items { - if v == item { - return true - } + var sb strings.Builder + if header := m.menuHeader(top); header != "" { + sb.WriteString(header) } - return false -} - -func (m *model) refreshLastSelection() { - m.lastSelection = nil - - if m.state.LastProfileName == "" || m.state.LastBackendType == "" { - return + if top.Title != "" { + sb.WriteString(titleStyle.Render(top.Title)) + sb.WriteString("\n") } - - var selectedProfile profiles.Profile - var selectedBackend profiles.Backend - for _, p := range m.installedProfiles { - if p.Name() != m.state.LastProfileName { + cursor := m.cursor() + auto := 1 + for i, it := range top.Items { + if it.Hidden { continue } - for _, b := range p.SupportedBackends() { - if string(b.Type) == m.state.LastBackendType { - selectedProfile = p - selectedBackend = b - break - } + var d int + isZero := false + switch it.Digit { + case 0: + d = auto + auto++ + case menu.DigitZero: + d = 0 + isZero = true + default: + d = it.Digit + } + label := fmt.Sprintf(" [%d] %s", d, it.Label) + if it.Description != "" { + label += " " + dimStyle.Render(it.Description) + } + if it.Disabled { + label = dimStyle.Render(label) + } else if i == cursor { + label = selectedStyle.Render(label) + } + sb.WriteString(label) + sb.WriteString("\n") + if isZero { + sb.WriteString("\n") } - break - } - if selectedProfile == nil { - return } - - provider, ok := m.resolveLastProvider(selectedProfile, selectedBackend) - if !ok { - return - } - - selectedModel, ok := m.resolveLastModel(selectedProfile, selectedBackend, provider) - if !ok { - return + sb.WriteString("\n") + if top.Hint != "" { + sb.WriteString(dimStyle.Render(top.Hint)) + sb.WriteString("\n") } - - m.lastSelection = &resolvedSelection{ - combo: profiles.Combo{ - Profile: selectedProfile, - Backend: selectedBackend, - }, - provider: provider, - selectedModel: selectedModel, + if len(m.stack) == 1 && m.buildVersion != "" { + sb.WriteString("\n") + sb.WriteString(dimStyle.Render("Aperture " + m.buildVersion)) + sb.WriteString("\n") } + return sb.String() } -func (m model) resolveLastProvider(p profiles.Profile, b profiles.Backend) (profiles.ProviderInfo, bool) { - var candidates []profiles.ProviderInfo - for _, provider := range m.manager.CompatibleProviders(p, m.providers) { - for _, supportedBackend := range m.manager.BackendsForProvider(p, provider) { - if supportedBackend.Type == b.Type { - candidates = append(candidates, provider) - break - } +// menuHeader returns the one-line status banner shown above certain menus: +// the root menu shows the connected endpoint; the endpoints menu in +// preflight-failure mode shows the red "couldn't reach" banner. +func (m *model) menuHeader(top *menu.Menu) string { + if len(m.stack) == 1 && top.Title == rootTitle { + header := dotGreen + " Connected to " + m.g.ApertureHost + if n := len(m.g.Providers); n > 0 { + header += fmt.Sprintf(" (%d providers)", n) } + return header + "\n\n" } - if len(candidates) == 0 { - return profiles.ProviderInfo{}, false - } - - if m.state.LastProviderID != "" { - for _, provider := range candidates { - if provider.ID == m.state.LastProviderID { - return provider, true - } + if m.forcedToEndpoint && top.Title == endpointsTitle { + header := dotRed + " Could not reach " + m.g.ApertureHost + "\n" + if m.preflightErr != "" { + header += dimStyle.Render(" "+m.preflightErr) + "\n" } - return profiles.ProviderInfo{}, false - } - - if len(candidates) == 1 { - return candidates[0], true + return header + "\n" } - return profiles.ProviderInfo{}, false + return "" } -func (m model) resolveLastModel(p profiles.Profile, b profiles.Backend, provider profiles.ProviderInfo) (string, bool) { - if !wantsModelSelection(p, b) { - return "", true - } - - models := fqnModels(provider) - if len(models) == 0 { - return "", true - } - - if m.state.LastModel != "" && containsString(models, m.state.LastModel) { - return m.state.LastModel, true - } +// --- Stack helpers --- - if len(models) == 1 { - return models[0], true +func (m *model) top() *menu.Menu { + if len(m.stack) == 0 { + return nil } - - return "", false + return m.stack[len(m.stack)-1] } -func upsertLocation(s profiles.Settings, loc string) profiles.Settings { - for _, ep := range s.Endpoints { - if ep.URL == loc { - return s - } +func (m *model) cursor() int { + if len(m.cursors) == 0 { + return 0 } - s.Endpoints = append(s.Endpoints, profiles.Endpoint{URL: loc}) - return s + return m.cursors[len(m.cursors)-1] } -func (m model) checkAndExecSelectedBackend() (model, tea.Cmd) { - if m.backendCursor < 0 || m.backendCursor >= len(m.backendItems) { - return m, nil +func (m *model) setCursor(c int) { + if len(m.cursors) == 0 { + return } - b := m.backendItems[m.backendCursor] + m.cursors[len(m.cursors)-1] = c +} - // If the profile supports model selection and the provider has multiple - // models, show the model picker instead of launching immediately. - wantsModel := wantsModelSelection(m.chosenProfile, b) - if wantsModel && len(m.chosenProvider.Models) > 1 { - m.chosenBackend = b - m.modelItems = fqnModels(m.chosenProvider) - m.modelCursor = 0 - m.step = stepSelectModel - return m, nil +func (m *model) popOne() { + if len(m.stack) <= 1 { + return } + m.stack = m.stack[:len(m.stack)-1] + m.cursors = m.cursors[:len(m.cursors)-1] +} - if checker, ok := m.chosenProfile.(profiles.Checker); ok { - if err := checker.Check(b); err != nil { - m.err = err.Error() - m.step = stepCheckError - return m, nil - } +func (m *model) popToRoot() { + if len(m.stack) > 1 { + m.stack = m.stack[:1] + m.cursors = m.cursors[:1] } - combo := profiles.Combo{Profile: m.chosenProfile, Backend: b} - return m, m.execCombo(combo) } -func (m model) execSelection(selection resolvedSelection) tea.Cmd { - m.chosenProvider = selection.provider - m.selectedModel = selection.selectedModel - return m.execCombo(selection.combo) +func (m *model) resetStack(root *menu.Menu) { + m.stack = []*menu.Menu{root} + m.cursors = []int{0} } -func (m model) execCombo(combo profiles.Combo) tea.Cmd { - // Desktop app profiles update config if needed and launch the app. - // The launch returns immediately (unlike CLI profiles which block). - if launcher, ok := combo.Profile.(profiles.Launcher); ok { - _ = profiles.SaveState(profiles.StateFile{ - LastProfileName: combo.Profile.Name(), - LastBackendType: string(combo.Backend.Type), - LastProviderID: m.chosenProvider.ID, - LastModel: m.selectedModel, - }) - host := m.apertureHost - return func() tea.Msg { - return launchDoneMsg{err: launcher.Launch(host)} - } - } - - env, err := combo.Profile.Env(m.apertureHost, combo.Backend) - if err != nil { - return tea.Quit - } - - if ps, ok := combo.Profile.(profiles.ProviderEnvSetter); ok { - for k, v := range ps.ProviderEnv(combo.Backend, []profiles.ProviderInfo{m.chosenProvider}) { - env[k] = v - } - } - - if m.selectedModel != "" { - if ms, ok := combo.Profile.(profiles.ModelSelector); ok { - ms.ApplyModel(m.selectedModel, env) - } - } - - binary := profiles.FindBinary(combo.Profile) - if binary == "" { - binary = combo.Profile.BinaryName() - } - - _ = profiles.SaveState(profiles.StateFile{ - LastProfileName: combo.Profile.Name(), - LastBackendType: string(combo.Backend.Type), - LastProviderID: m.chosenProvider.ID, - LastModel: m.selectedModel, - }) - - envPairs := os.Environ() - for k, v := range env { - envPairs = append(envPairs, k+"="+v) - } - - var configCleanup func() - var configEnvKey, configPath string - if cw, ok := combo.Profile.(profiles.ProviderConfigWriter); ok { - var err error - configEnvKey, configPath, configCleanup, err = cw.WriteProviderConfig(m.apertureHost, combo.Backend, m.chosenProvider) - if err != nil { - return tea.Quit - } - if configEnvKey != "" && configPath != "" { - envPairs = append(envPairs, configEnvKey+"="+configPath) - } - } else if cw, ok := combo.Profile.(profiles.ConfigWriter); ok { - var err error - configEnvKey, configPath, configCleanup, err = cw.WriteConfig(m.apertureHost, combo.Backend) - if err != nil { - return tea.Quit - } - if configEnvKey != "" && configPath != "" { - envPairs = append(envPairs, configEnvKey+"="+configPath) - } +// skipHiddenUp advances the cursor backward past hidden items. +func (m *model) skipHiddenUp() { + top := m.top() + if top == nil { + return } - - if m.debug { - fmt.Fprintf(os.Stderr, "\r\n[debug] launching %s with env:\r\n", binary) - for k, v := range env { - fmt.Fprintf(os.Stderr, "[debug] %s=%s\r\n", k, v) - } - if configEnvKey != "" && configPath != "" { - fmt.Fprintf(os.Stderr, "[debug] %s=%s\r\n", configEnvKey, configPath) - } + c := m.cursor() + for c > 0 && top.Items[c].Hidden { + c-- } + m.setCursor(c) +} - var extraArgs []string - if m.selectedModel != "" { - if ma, ok := combo.Profile.(profiles.ModelArgSelector); ok { - extraArgs = append(extraArgs, ma.ModelArgs(m.selectedModel)...) - } - } - if m.settings.YoloMode { - if yp, ok := combo.Profile.(profiles.YoloProfile); ok { - extraArgs = append(extraArgs, yp.YoloArgs()...) - } +func (m *model) skipHiddenDown() { + top := m.top() + if top == nil { + return } - - if m.debug && len(extraArgs) > 0 { - fmt.Fprintf(os.Stderr, "[debug] args: %v\r\n", extraArgs) + c := m.cursor() + for c < len(top.Items)-1 && top.Items[c].Hidden { + c++ } - - cmd := exec.Command(binary, extraArgs...) - cmd.Env = envPairs - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return tea.ExecProcess(cmd, func(err error) tea.Msg { - if configCleanup != nil { - configCleanup() - } - return execDoneMsg{err: err} - }) + m.setCursor(c) } -func (m model) View() string { - var sb strings.Builder - - switch m.step { - case stepPreflight: - sb.WriteString(dotYellow + " Checking " + m.apertureHost + " …\n") - - case stepCheckError: - sb.WriteString(errorStyle.Render("Cannot launch")) - sb.WriteString("\n\n") - sb.WriteString(m.err) - sb.WriteString("\n\n") - sb.WriteString(dimStyle.Render("Esc to go back · q to quit\n")) - - case stepError: - sb.WriteString(errorStyle.Render("Error: " + m.err)) - sb.WriteString("\n\nPress any key to exit.\n") - - case stepSelectProfile: - sb.WriteString(dotGreen + " Connected to " + m.apertureHost) - if len(m.providers) > 0 { - sb.WriteString(fmt.Sprintf(" (%d providers)", len(m.providers))) - } - sb.WriteString("\n\n") - sb.WriteString(titleStyle.Render("Which editor do you want to use?")) - sb.WriteString("\n") - - if m.lastSelection != nil { - label := fmt.Sprintf(" [0] Quick select: %s via %s - %s", - m.lastSelection.combo.Profile.Name(), - m.lastSelection.provider.DisplayName(), - m.lastSelection.combo.Backend.DisplayName) - if m.lastSelection.selectedModel != "" { - label += " - " + m.lastSelection.selectedModel - } - if m.profileCursor == -1 { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - sb.WriteString("\n") - } - - for i, p := range m.installedProfiles { - n := i + 1 - label := fmt.Sprintf(" [%d] %s", n, p.Name()) - if m.profileCursor == i { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - } - - sb.WriteString("\n") - - // Keyboard shortcut hints. - hints := []string{"[s] Settings"} - if len(m.uninstalledProfiles()) > 0 { - hints = append(hints, "[i] Install agents") - } - hints = append(hints, "[q] Quit") - sb.WriteString(dimStyle.Render(" " + strings.Join(hints, " "))) - sb.WriteString("\n") - - sb.WriteString("\n") - sb.WriteString(dimStyle.Render("Selection: ")) - - if m.buildVersion != "" { - sb.WriteString("\n\n") - sb.WriteString(dimStyle.Render("Aperture " + m.buildVersion)) - sb.WriteString("\n") - } - - case stepSelectProvider: - sb.WriteString(titleStyle.Render(fmt.Sprintf("Choose a provider for %s:", m.chosenProfile.Name()))) - sb.WriteString("\n") - - for i, prov := range m.providerItems { - label := fmt.Sprintf(" [%d] %s", i+1, prov.DisplayName()) - if prov.Description != "" { - label += " " + dimStyle.Render(prov.Description) - } - if m.providerCursor == i { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - } - - sb.WriteString("\n") - sb.WriteString(dimStyle.Render("Selection: ")) +// --- Input step helpers --- - case stepInstallAgents: - sb.WriteString(titleStyle.Render("Install agents")) - sb.WriteString("\n") - uninstalled := m.uninstalledProfiles() - if len(uninstalled) == 0 { - sb.WriteString(dimStyle.Render(" All agents are installed.\n")) - } else { - for i, p := range uninstalled { - label := fmt.Sprintf(" [%d] %s", i+1, p.Name()) - if m.installAgentsCursor == i { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - } - } - sb.WriteString("\n") - sb.WriteString(dimStyle.Render("Enter to select · Esc to go back\n")) - - case stepSelectBackend: - sb.WriteString(titleStyle.Render(fmt.Sprintf("Choose a backend for %s via %s:", - m.chosenProfile.Name(), m.chosenProvider.DisplayName()))) - sb.WriteString("\n") - - for i, b := range m.backendItems { - label := fmt.Sprintf(" [%d] %s", i+1, b.DisplayName) - if m.backendCursor == i { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - } - - sb.WriteString("\n") - sb.WriteString(dimStyle.Render("Selection: ")) - - case stepSelectModel: - sb.WriteString(titleStyle.Render(fmt.Sprintf("Choose a default model for %s via %s:", - m.chosenProfile.Name(), m.chosenProvider.DisplayName()))) - sb.WriteString("\n") - - for i, model := range m.modelItems { - label := fmt.Sprintf(" [%d] %s", i+1, model) - if m.modelCursor == i { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - } - - sb.WriteString("\n") - sb.WriteString(dimStyle.Render("Selection: ")) - - case stepSettings: - sb.WriteString(titleStyle.Render("Settings")) - sb.WriteString("\n") - for i, row := range m.settingsRows() { - var renderedRow string - if i == m.settingsYoloIdx() && m.settings.YoloMode { - renderedRow = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true).Render(row) - } else { - renderedRow = row - } - label := fmt.Sprintf(" [%d] %s", i+1, renderedRow) - if m.settingsCursor == i { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - } - sb.WriteString("\n") - sb.WriteString(dimStyle.Render("Enter to select · Esc to go back\n")) - - case stepEndpoints: - if m.endpointsFromSetup { - sb.WriteString(dotRed + " Could not reach " + m.apertureHost + "\n") - if m.preflightErr != "" { - sb.WriteString(dimStyle.Render(" "+m.preflightErr) + "\n") - } - sb.WriteString("\n") - } - sb.WriteString(titleStyle.Render("Aperture Endpoints")) - sb.WriteString("\n") - rows := m.endpointsRows() - for i, row := range rows { - if i == 0 { - row = greenStyle.Render(row) - } - label := fmt.Sprintf(" [%d] %s", i+1, row) - if m.endpointsCursor == i { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - } - sb.WriteString("\n") - sb.WriteString(" [a] Add endpoint\n") - sb.WriteString("\n") - escHint := "Esc to go back" - if m.endpointsFromSetup { - escHint = "Esc to quit" - } - sb.WriteString(dimStyle.Render("Enter to select · d to remove · a to add · " + escHint + "\n")) - - case stepInstall: - sb.WriteString(titleStyle.Render("Install " + m.chosenProfile.Name() + "?")) - sb.WriteString("\n") - if inst, ok := m.chosenProfile.(profiles.Installer); ok { - if _, isHA := m.chosenProfile.(profiles.HostAwareInstaller); isHA { - sb.WriteString(" " + inst.InstallHint() + "\n") - } else { - sb.WriteString(" This will run: " + inst.InstallHint() + "\n") - } - } - sb.WriteString("\n") - sb.WriteString(dimStyle.Render("y to install · Enter/Esc to cancel\n")) - - case stepUninstall: - sb.WriteString(titleStyle.Render("Uninstall")) - sb.WriteString("\n") - if len(m.installedProfiles) == 0 { - sb.WriteString(dimStyle.Render(" No agents installed.\n")) - } else { - for i, p := range m.installedProfiles { - label := fmt.Sprintf(" [%d] %s", i+1, p.Name()) - if m.uninstallCursor == i { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - } - } - sb.WriteString("\n") - sb.WriteString(dimStyle.Render("Enter to select · Esc to go back\n")) - - case stepUninstallConfirm: - sb.WriteString(titleStyle.Render("Uninstall " + m.chosenProfile.Name() + "?")) - sb.WriteString("\n") - if uninst, ok := m.chosenProfile.(profiles.Uninstaller); ok { - sb.WriteString(" This will run: " + uninst.UninstallHint() + "\n") - } - sb.WriteString("\n") - sb.WriteString(dimStyle.Render("y to uninstall · Enter/Esc to cancel\n")) +// promptForInput sets up the single-line text input step. onSave is invoked +// with the entered value when the user presses Enter. +func (m *model) promptForInput(title, prompt string, onSave func(value string) tea.Cmd) { + m.step = stepInput + m.inputTitle = title + m.inputPrompt = prompt + m.inputValue = "" + m.inputOnSave = onSave +} - case stepAddLocation: - sb.WriteString(titleStyle.Render("Add Endpoint:")) - sb.WriteString("\n") - sb.WriteString(" > " + m.addLocationInput + "█\n") - sb.WriteString("\n") - sb.WriteString(dimStyle.Render("Press Enter to save, Esc to cancel.\n")) - } +// --- Registered clients access --- - return sb.String() +// registeredClients is the set visible to the TUI; overridable in tests. +var registeredClients = func(g *config.Global) []clients.Client { + return clients.All(g) } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 7b18f14..a66a46d 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -5,119 +5,179 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" - "github.com/tailscale/aperture-cli/internal/profiles" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/menu" ) -type quickTestProfile struct{} +// fakeClient is a minimal clients.Client for TUI tests. +type fakeClient struct { + name string + installed bool + replayCmd tea.Cmd + quickLabel string + menuActions *menu.Menu // returned as Next from top-level action +} -func (quickTestProfile) Name() string { return "Test Agent" } -func (quickTestProfile) BinaryName() string { return "test-agent" } -func (quickTestProfile) SupportedBackends() []profiles.Backend { - return []profiles.Backend{ - {Type: profiles.BackendAnthropic, DisplayName: "Anthropic"}, - } +func (c *fakeClient) Name() string { return c.name } +func (c *fakeClient) BinaryName() string { return "fake" } +func (c *fakeClient) CommonPaths() []string { return nil } +func (c *fakeClient) IsInstalled() bool { return c.installed } +func (c *fakeClient) Install(*config.Global) clients.InstallPlan { + return clients.InstallPlan{Hint: "install " + c.name} } -func (quickTestProfile) Env(string, profiles.Backend) (map[string]string, error) { - return map[string]string{}, nil +func (c *fakeClient) Uninstall() clients.UninstallPlan { + return clients.UninstallPlan{Hint: "uninstall " + c.name} } -func (quickTestProfile) RequiredCompat(profiles.Backend) []string { - return []string{"anthropic_messages"} +func (c *fakeClient) Menu(*config.Global) menu.MenuItem { + return menu.MenuItem{ + Label: c.name, + Action: func() menu.Result { return menu.Result{Next: c.menuActions} }, + } } -func (quickTestProfile) ApplyModel(model string, env map[string]string) { - env["MODEL"] = model +func (c *fakeClient) Replay(*config.Global) tea.Cmd { return c.replayCmd } +func (c *fakeClient) QuickSelectLabel(*config.Global) string { return c.quickLabel } + +// withFakeClients swaps the TUI's client registry for the duration of the test. +func withFakeClients(t *testing.T, cs []clients.Client) { + t.Helper() + orig := registeredClients + registeredClients = func(*config.Global) []clients.Client { return cs } + t.Cleanup(func() { registeredClients = orig }) } -func TestRefreshLastSelectionResolvesProviderAndModel(t *testing.T) { - p := quickTestProfile{} - m := model{ - apertureHost: "http://ai", - state: profiles.StateFile{LastProfileName: p.Name(), LastBackendType: string(profiles.BackendAnthropic), LastProviderID: "provider-one", LastModel: "provider-one/model-b"}, - manager: profiles.NewManager(), - installedProfiles: []profiles.Profile{p}, - providers: []profiles.ProviderInfo{ - { - ID: "provider-one", - Name: "Provider One", - Models: []string{"model-a", "model-b"}, - Compatibility: map[string]bool{"anthropic_messages": true}, - }, - }, - step: stepSelectProfile, +func TestRootMenu_ShowsInstalledClients(t *testing.T) { + withFakeClients(t, []clients.Client{ + &fakeClient{name: "A", installed: true}, + &fakeClient{name: "B", installed: false}, + &fakeClient{name: "C", installed: true}, + }) + + m := &model{g: &config.Global{}} + root := m.rootMenu() + // Installed clients + hidden shortcut items (settings + install-agents). + // Visible count: A, C (2). Plus a hidden Settings and hidden Install agents. + visible := 0 + for _, it := range root.Items { + if !it.Hidden { + visible++ + } } - - m.refreshLastSelection() - - if m.lastSelection == nil { - t.Fatal("lastSelection is nil") + if visible != 2 { + t.Errorf("visible items = %d, want 2", visible) } - if got := m.lastSelection.provider.ID; got != "provider-one" { - t.Fatalf("provider ID = %q, want %q", got, "provider-one") +} + +func TestRootMenu_QuickSelectPrepended(t *testing.T) { + replayed := false + fc := &fakeClient{ + name: "A", + installed: true, + replayCmd: func() tea.Msg { replayed = true; return menu.ExecDoneMsg{} }, + quickLabel: "A via Whatever", } - if got := m.lastSelection.selectedModel; got != "provider-one/model-b" { - t.Fatalf("selected model = %q, want %q", got, "provider-one/model-b") + withFakeClients(t, []clients.Client{fc}) + + m := &model{g: &config.Global{ + LastLaunch: config.LaunchState{LastClientName: "A"}, + }} + root := m.rootMenu() + + // First visible item should be the quick-select row with Digit=0. + var first menu.MenuItem + for _, it := range root.Items { + if !it.Hidden { + first = it + break + } } - - view := m.View() - if !strings.Contains(view, "[0] Quick select: Test Agent via Provider One - Anthropic - provider-one/model-b") { - t.Fatalf("View() missing quick select row:\n%s", view) + if first.Digit != menu.DigitZero { + t.Errorf("first visible Digit = %d, want DigitZero", first.Digit) } - if strings.Index(view, "[0] Quick select") > strings.Index(view, "[1] Test Agent") { - t.Fatalf("quick select row should appear before profile options:\n%s", view) + if !strings.Contains(first.Label, "Quick select") { + t.Errorf("first visible Label = %q", first.Label) } - m.resetProfileCursor() - if m.profileCursor != -1 { - t.Fatalf("profileCursor = %d, want -1 for quick select", m.profileCursor) + // Invoking the action should run the replay cmd. + res := first.Action() + if res.Cmd == nil { + t.Fatal("quick select action returned nil Cmd") } - - updated, _ := m.updateSelectProfile(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(model) - if m.profileCursor != 0 { - t.Fatalf("profileCursor after down = %d, want 0", m.profileCursor) + _ = res.Cmd() // run it + if !replayed { + t.Error("replay cmd was not invoked") } +} - updated, _ = m.updateSelectProfile(tea.KeyMsg{Type: tea.KeyUp}) - m = updated.(model) - if m.profileCursor != -1 { - t.Fatalf("profileCursor after up = %d, want -1", m.profileCursor) +func TestRootMenu_NoQuickSelectWhenReplayNil(t *testing.T) { + fc := &fakeClient{name: "A", installed: true, replayCmd: nil} + withFakeClients(t, []clients.Client{fc}) + + m := &model{g: &config.Global{ + LastLaunch: config.LaunchState{LastClientName: "A"}, + }} + root := m.rootMenu() + for _, it := range root.Items { + if !it.Hidden && strings.Contains(it.Label, "Quick select") { + t.Errorf("unexpected quick-select row: %+v", it) + } } } -func TestRefreshLastSelectionRejectsMissingModel(t *testing.T) { - p := quickTestProfile{} - m := model{ - state: profiles.StateFile{LastProfileName: p.Name(), LastBackendType: string(profiles.BackendAnthropic), LastProviderID: "provider-one", LastModel: "provider-one/missing"}, - manager: profiles.NewManager(), - installedProfiles: []profiles.Profile{p}, - providers: []profiles.ProviderInfo{ - { - ID: "provider-one", - Name: "Provider One", - Models: []string{"model-a", "model-b"}, - Compatibility: map[string]bool{"anthropic_messages": true}, - }, +func TestMenuEngine_PushPop(t *testing.T) { + sub := &menu.Menu{ + Title: "Sub", + Items: []menu.MenuItem{ + {Label: "ok", Action: func() menu.Result { return menu.Result{Pop: true} }}, }, } + fc := &fakeClient{name: "A", installed: true, menuActions: sub} + withFakeClients(t, []clients.Client{fc}) + + m := &model{g: &config.Global{}, step: stepMenu} + m.resetStack(m.rootMenu()) + + // Select the visible "A" item (first non-hidden). + var idx int + for i, it := range m.top().Items { + if !it.Hidden { + idx = i + break + } + } + mm, _ := m.activate(idx) + m = mm.(*model) + if m.top().Title != "Sub" { + t.Fatalf("top after push = %q, want Sub", m.top().Title) + } - m.refreshLastSelection() - - if m.lastSelection != nil { - t.Fatalf("lastSelection = %#v, want nil", m.lastSelection) + // Activate the Pop item. + mm, _ = m.activate(0) + m = mm.(*model) + if m.top().Title != rootTitle { + t.Fatalf("top after pop = %q, want %q", m.top().Title, rootTitle) } } -func TestSelectProfileViewShowsBuildVersion(t *testing.T) { - m := model{ - apertureHost: "http://ai", - buildVersion: "B123", - manager: profiles.NewManager(), - providers: []profiles.ProviderInfo{{ID: "provider-one"}}, - profileCursor: 0, - step: stepSelectProfile, - } +func TestSettingsMenu_ToggleYolo(t *testing.T) { + withFakeClients(t, nil) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + + g := &config.Global{} + m := &model{g: g, step: stepMenu} + m.resetStack(m.settingsMenu()) - view := m.View() - if !strings.Contains(view, "Aperture B123") { - t.Fatalf("View() missing build version:\n%s", view) + // YOLO is the 3rd item. + res := m.top().Items[2].Action() + if !g.Settings.YoloMode { + t.Error("YoloMode = false after toggle") + } + if res.Replace == nil { + t.Fatal("toggle should replace menu in place") + } + if !strings.Contains(res.Replace.Items[2].Label, "YOLO mode: on") { + t.Errorf("new label = %q", res.Replace.Items[2].Label) } } From a7026b095c2fda0b1ca6156f01eaeac23ac43099 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Fri, 1 May 2026 03:26:09 +0000 Subject: [PATCH 12/15] opencode: skip model picker, launch straight from provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode exposes a native model switcher, so selecting a model in the Aperture TUI was redundant. The flow is now Main Menu → OpenCode → Pick Provider → launch; the user picks a model inside OpenCode itself. --- internal/clients/opencode/opencode.go | 59 +++++---------------------- 1 file changed, 10 insertions(+), 49 deletions(-) diff --git a/internal/clients/opencode/opencode.go b/internal/clients/opencode/opencode.go index 1538f4b..45fd086 100644 --- a/internal/clients/opencode/opencode.go +++ b/internal/clients/opencode/opencode.go @@ -2,15 +2,14 @@ // OpenCode has a single abstract routing flavor: the real protocol (OpenAI // Responses, OpenAI Chat, Anthropic Messages, Bedrock, Vertex, Gemini) is // decided at launch time from the chosen provider's compatibility map. The -// Menu flow therefore skips the backend step and goes straight from -// provider selection to model selection. +// Menu flow goes straight from provider selection to launch; model +// selection happens inside OpenCode itself. package opencode import ( "os" "os/exec" "path/filepath" - "slices" tea "github.com/charmbracelet/bubbletea" "github.com/tailscale/aperture-cli/internal/clients" @@ -98,14 +97,14 @@ func (c *Client) providerStep(g *config.Global) menu.Result { return errorResult("No providers support an OpenCode protocol.") } if len(provs) == 1 { - return c.modelStep(g, provs[0]) + return c.launch(g, provs[0]) } items := make([]menu.MenuItem, 0, len(provs)) for _, p := range provs { items = append(items, menu.MenuItem{ Label: p.DisplayName(), Description: p.Description, - Action: func() menu.Result { return c.modelStep(g, p) }, + Action: func() menu.Result { return c.launch(g, p) }, }) } return menu.Result{Next: &menu.Menu{ @@ -114,29 +113,7 @@ func (c *Client) providerStep(g *config.Global) menu.Result { }} } -func (c *Client) modelStep(g *config.Global, p config.ProviderInfo) menu.Result { - models := fqnModels(p) - if len(models) <= 1 { - var m string - if len(models) == 1 { - m = models[0] - } - return c.launch(g, p, m) - } - items := make([]menu.MenuItem, 0, len(models)) - for _, m := range models { - items = append(items, menu.MenuItem{ - Label: m, - Action: func() menu.Result { return c.launch(g, p, m) }, - }) - } - return menu.Result{Next: &menu.Menu{ - Title: "Choose a default model for " + name + " via " + p.DisplayName() + ":", - Items: items, - }} -} - -func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) menu.Result { +func (c *Client) launch(g *config.Global, p config.ProviderInfo) menu.Result { bin := clients.FindBinary(binaryName, c.CommonPaths()) if bin == "" { bin = binaryName @@ -156,13 +133,13 @@ func (c *Client) launch(g *config.Global, p config.ProviderInfo, model string) m env["AWS_REGION"] = "us-east-1" } - // OpenCode has no documented yolo flag today; keep Args empty. Model is - // conveyed via the provider config written above, not a CLI arg. + // OpenCode has no documented yolo flag today; keep Args empty. The + // provider config written above conveys available models; the user + // picks one inside OpenCode itself. _ = g.RecordLaunch(config.LaunchState{ LastClientName: name, LastBackendType: "openai", // historical; OpenCode's abstract backend LastProviderID: p.ID, - LastModel: model, }) cmd := clients.Launch(clients.LaunchSpec{ @@ -186,22 +163,14 @@ func (c *Client) Replay(g *config.Global) tea.Cmd { if !providerMatches(prov) { return nil } - model := g.LastLaunch.LastModel - if model != "" && !slices.Contains(fqnModels(prov), model) { - return nil - } - res := c.launch(g, prov, model) + res := c.launch(g, prov) return res.Cmd } // QuickSelectLabel implements clients.Client. func (c *Client) QuickSelectLabel(g *config.Global) string { prov, _ := g.Provider(g.LastLaunch.LastProviderID) - label := name + " via " + prov.DisplayName() - if g.LastLaunch.LastModel != "" { - label += " - " + g.LastLaunch.LastModel - } - return label + return name + " via " + prov.DisplayName() } func compatibleProviders(all []config.ProviderInfo) []config.ProviderInfo { @@ -223,14 +192,6 @@ func providerMatches(p config.ProviderInfo) bool { return false } -func fqnModels(p config.ProviderInfo) []string { - out := make([]string, len(p.Models)) - for i, m := range p.Models { - out[i] = p.ID + "/" + m - } - return out -} - func errorResult(msg string) menu.Result { return menu.Result{Cmd: func() tea.Msg { return menu.SimpleDoneMsg{Err: errString(msg)} From 9e521192ae21e42fba1b58ba177fcf0cf659c9ef Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Fri, 1 May 2026 03:26:26 +0000 Subject: [PATCH 13/15] tui: expand menu tokens past 9 and add 2-column layout Long menus (e.g. the OpenCode provider list after all models roll up) ran past single-digit shortcuts, leaving items unreachable by key. Auto-numbering now draws from 1-9, a-z, A-Z in order, skipping any key already claimed by an explicit Shortcut or Digit. On terminals wider than 80 columns, menus with 10+ rows render in two columns when the widest row fits twice across. Left/Right (and h/l) move between columns; Up/Down stay within the current column. The key handler and renderer share a single menuLayout helper so navigation can't disagree with what's drawn. --- internal/tui/tui.go | 290 +++++++++++++++++++++++++++------------ internal/tui/tui_test.go | 47 +++++++ 2 files changed, 253 insertions(+), 84 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a1f5f4c..ff9ac79 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -59,6 +59,10 @@ type model struct { step step + // Terminal dimensions, refreshed on tea.WindowSizeMsg. Zero until the + // first message arrives. + width, height int + // Menu stack. The top (last element) is what's rendered and receives key // input during stepMenu. stack []*menu.Menu @@ -119,6 +123,11 @@ func runPreflight(host string) tea.Cmd { func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case preflightResult: if msg.err != nil { m.preflightErr = msg.err.Error() @@ -224,16 +233,36 @@ func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.ClearScreen case "up", "k": - if cursor > 0 { - m.setCursor(cursor - 1) - m.skipHiddenUp() + visible, _, _ := m.menuLayout(top) + if p := visiblePos(visible, cursor); p > 0 { + m.setCursor(visible[p-1]) } return m, nil case "down", "j": - if cursor < len(top.Items)-1 { - m.setCursor(cursor + 1) - m.skipHiddenDown() + visible, _, _ := m.menuLayout(top) + if p := visiblePos(visible, cursor); p >= 0 && p < len(visible)-1 { + m.setCursor(visible[p+1]) + } + return m, nil + + case "left", "h": + visible, twoCols, half := m.menuLayout(top) + if !twoCols { + return m, nil + } + if p := visiblePos(visible, cursor); p >= half { + m.setCursor(visible[p-half]) + } + return m, nil + + case "right", "l": + visible, twoCols, half := m.menuLayout(top) + if !twoCols { + return m, nil + } + if p := visiblePos(visible, cursor); p >= 0 && p < half && p+half < len(visible) { + m.setCursor(visible[p+half]) } return m, nil @@ -242,39 +271,24 @@ func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { default: s := msg.String() - // Digit shortcut: activate item with matching Digit. Auto-numbered - // items count in visible order, skipping any item with an explicit - // Digit assignment. - if len(s) == 1 && s[0] >= '0' && s[0] <= '9' { - auto := 1 - for i, it := range top.Items { - if it.Hidden || it.Disabled { - continue - } - var d int - switch it.Digit { - case 0: - d = auto - auto++ - case menu.DigitZero: - d = 0 - default: - d = it.Digit - } - if s[0]-'0' == byte(d) { - return m.activate(i) - } + if len(s) != 1 { + return m, nil + } + // Single-char shortcut (explicit Shortcut wins over auto-assigned + // tokens so e.g. "d" on the endpoints menu always deletes). + for i, it := range top.Items { + if it.Hidden || it.Disabled { + continue + } + if it.Shortcut != "" && it.Shortcut == s { + return m.activate(i) } } - // Single-char shortcut. - if len(s) == 1 { - for i, it := range top.Items { - if it.Hidden || it.Disabled { - continue - } - if it.Shortcut != "" && it.Shortcut == s { - return m.activate(i) - } + // Auto-assigned or explicit-Digit token. + tokens := assignTokens(top.Items) + for i, tok := range tokens { + if tok != "" && tok == s { + return m.activate(i) } } } @@ -398,37 +412,58 @@ func (m *model) viewMenu() string { sb.WriteString("\n") } cursor := m.cursor() - auto := 1 - for i, it := range top.Items { - if it.Hidden { - continue + tokens := assignTokens(top.Items) + visible, twoCols, half := m.menuLayout(top) + + plains := make(map[int]string, len(visible)) + styleds := make(map[int]string, len(visible)) + maxW := 0 + for _, i := range visible { + it := top.Items[i] + tok := tokens[i] + if tok == "" { + tok = " " } - var d int - isZero := false - switch it.Digit { - case 0: - d = auto - auto++ - case menu.DigitZero: - d = 0 - isZero = true - default: - d = it.Digit + plain := fmt.Sprintf(" [%s] %s", tok, it.Label) + if it.Description != "" { + plain += " " + it.Description } - label := fmt.Sprintf(" [%d] %s", d, it.Label) + styled := fmt.Sprintf(" [%s] %s", tok, it.Label) if it.Description != "" { - label += " " + dimStyle.Render(it.Description) + styled += " " + dimStyle.Render(it.Description) } if it.Disabled { - label = dimStyle.Render(label) + styled = dimStyle.Render(styled) } else if i == cursor { - label = selectedStyle.Render(label) + styled = selectedStyle.Render(styled) } - sb.WriteString(label) - sb.WriteString("\n") - if isZero { + plains[i] = plain + styleds[i] = styled + if w := len(plain); w > maxW { + maxW = w + } + } + + if twoCols { + colWidth := maxW + 4 + for r := 0; r < half; r++ { + li := visible[r] + sb.WriteString(styleds[li]) + sb.WriteString(strings.Repeat(" ", colWidth-len(plains[li]))) + if r+half < len(visible) { + ri := visible[r+half] + sb.WriteString(styleds[ri]) + } sb.WriteString("\n") } + } else { + for _, i := range visible { + sb.WriteString(styleds[i]) + sb.WriteString("\n") + if top.Items[i].Digit == menu.DigitZero { + sb.WriteString("\n") + } + } } sb.WriteString("\n") if top.Hint != "" { @@ -443,6 +478,118 @@ func (m *model) viewMenu() string { return sb.String() } +// menuLayout decides the visible order and column layout for a menu. +// visible is the list of Items indices that render (hidden rows skipped); +// twoCols is true when the wide-terminal / long-list two-column layout is +// active; half is len(visible) rounded up / 2 (the row count in each +// column). twoCols=false means half is unused. +func (m *model) menuLayout(top *menu.Menu) (visible []int, twoCols bool, half int) { + visible = make([]int, 0, len(top.Items)) + hasZero := false + for i, it := range top.Items { + if it.Hidden { + continue + } + if it.Digit == menu.DigitZero { + hasZero = true + } + visible = append(visible, i) + } + if m.width < 80 || len(visible) < 10 || hasZero { + return visible, false, 0 + } + tokens := assignTokens(top.Items) + maxW := 0 + for _, i := range visible { + it := top.Items[i] + tok := tokens[i] + if tok == "" { + tok = " " + } + w := len(" [] ") + len(tok) + len(it.Label) + if it.Description != "" { + w += 2 + len(it.Description) + } + if w > maxW { + maxW = w + } + } + if maxW*2+4 > m.width { + return visible, false, 0 + } + return visible, true, (len(visible) + 1) / 2 +} + +// visiblePos returns i's position within visible, or -1 if i isn't there. +func visiblePos(visible []int, i int) int { + for p, v := range visible { + if v == i { + return p + } + } + return -1 +} + +// autoTokens is the pool of single-character keys auto-assigned to menu +// items in visible order: 1-9, then a-z, then A-Z. "0" is reserved for the +// DigitZero pin; items that set an explicit Shortcut keep that key out of +// the pool. +var autoTokens = func() []string { + var out []string + for c := '1'; c <= '9'; c++ { + out = append(out, string(c)) + } + for c := 'a'; c <= 'z'; c++ { + out = append(out, string(c)) + } + for c := 'A'; c <= 'Z'; c++ { + out = append(out, string(c)) + } + return out +}() + +// assignTokens returns one token per Items slot. Hidden or disabled items +// and items without an Action get an empty string. Items with DigitZero get +// "0"; items with Digit>0 get that digit (legacy explicit assignments). +// Everything else is auto-numbered from the autoTokens pool, skipping any +// token already claimed by an item's Shortcut or explicit Digit. +func assignTokens(items []menu.MenuItem) []string { + tokens := make([]string, len(items)) + reserved := map[string]bool{} + for _, it := range items { + if it.Shortcut != "" { + reserved[it.Shortcut] = true + } + if it.Digit > 0 { + reserved[fmt.Sprintf("%d", it.Digit)] = true + } + } + pool := make([]string, 0, len(autoTokens)) + for _, t := range autoTokens { + if !reserved[t] { + pool = append(pool, t) + } + } + next := 0 + for i, it := range items { + if it.Hidden || it.Disabled || it.Action == nil { + continue + } + switch { + case it.Digit == menu.DigitZero: + tokens[i] = "0" + case it.Digit > 0: + tokens[i] = fmt.Sprintf("%d", it.Digit) + default: + if next < len(pool) { + tokens[i] = pool[next] + next++ + } + } + } + return tokens +} + // menuHeader returns the one-line status banner shown above certain menus: // the root menu shows the connected endpoint; the endpoints menu in // preflight-failure mode shows the red "couldn't reach" banner. @@ -507,31 +654,6 @@ func (m *model) resetStack(root *menu.Menu) { m.cursors = []int{0} } -// skipHiddenUp advances the cursor backward past hidden items. -func (m *model) skipHiddenUp() { - top := m.top() - if top == nil { - return - } - c := m.cursor() - for c > 0 && top.Items[c].Hidden { - c-- - } - m.setCursor(c) -} - -func (m *model) skipHiddenDown() { - top := m.top() - if top == nil { - return - } - c := m.cursor() - for c < len(top.Items)-1 && top.Items[c].Hidden { - c++ - } - m.setCursor(c) -} - // --- Input step helpers --- // promptForInput sets up the single-line text input step. onSave is invoked diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index a66a46d..c6f2a66 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -159,6 +159,53 @@ func TestMenuEngine_PushPop(t *testing.T) { } } +func TestAssignTokens_RollsIntoLetters(t *testing.T) { + var items []menu.MenuItem + for i := 0; i < 15; i++ { + items = append(items, menu.MenuItem{Label: "m", Action: func() menu.Result { return menu.Result{} }}) + } + got := assignTokens(items) + want := []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"} + for i, w := range want { + if got[i] != w { + t.Errorf("token[%d] = %q, want %q", i, got[i], w) + } + } +} + +func TestAssignTokens_SkipsReservedShortcuts(t *testing.T) { + items := []menu.MenuItem{ + {Label: "normal", Action: func() menu.Result { return menu.Result{} }}, + {Label: "normal", Action: func() menu.Result { return menu.Result{} }}, + {Label: "hidden", Shortcut: "d", Hidden: true, Action: func() menu.Result { return menu.Result{} }}, + } + got := assignTokens(items) + // Hidden item gets no token. + if got[2] != "" { + t.Errorf("hidden token = %q, want empty", got[2]) + } + // Auto tokens must not include "d". + for i := 0; i < 2; i++ { + if got[i] == "d" { + t.Errorf("auto token[%d] = %q, should skip reserved 'd'", i, got[i]) + } + } +} + +func TestAssignTokens_DigitZeroPinned(t *testing.T) { + items := []menu.MenuItem{ + {Label: "quick", Digit: menu.DigitZero, Action: func() menu.Result { return menu.Result{} }}, + {Label: "a", Action: func() menu.Result { return menu.Result{} }}, + } + got := assignTokens(items) + if got[0] != "0" { + t.Errorf("pinned token = %q, want 0", got[0]) + } + if got[1] != "1" { + t.Errorf("first auto = %q, want 1", got[1]) + } +} + func TestSettingsMenu_ToggleYolo(t *testing.T) { withFakeClients(t, nil) tmp := t.TempDir() From 8df093c776190d942f9fc6d127af31dded15f9b0 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Fri, 1 May 2026 04:09:58 +0000 Subject: [PATCH 14/15] clients,tui: fix Gemini HTTPS requirement and settings shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gemini: require an https:// FQDN Aperture endpoint and abort with a clear message otherwise. Gemini CLI 0.40+ rejects non-https custom base URLs (except literal localhost / 127.0.0.1) and strips any trailing slash. Also set GOOGLE_GEMINI_BASE_URL alongside the legacy GEMINI_BASE_URL so the new CLI picks up the gateway. - tui: allow hidden shortcut rows (Settings "s", Install "i", endpoint "a" add / "d" delete) to fire from shortcut keys, and don't move the cursor onto a hidden row when activating it — that was causing the endpoints "d" handler to read a nonsense cursor and hang. - tui: drop the redundant "Add endpoint" row from the endpoints menu; "a" shortcut in the footer is the single entry point. - codex, gemini: keep the pre-refactor config paths (aperture/codex-home, aperture/gemini-home) so existing on-disk state is still found. --- internal/clients/codex/config.go | 12 +++-- internal/clients/gemini/config.go | 9 ++-- internal/clients/gemini/gemini.go | 61 +++++++++++++++++++++++++- internal/clients/gemini/gemini_test.go | 23 ++++++++++ internal/tui/menus.go | 4 +- internal/tui/tui.go | 12 ++++- 6 files changed, 110 insertions(+), 11 deletions(-) diff --git a/internal/clients/codex/config.go b/internal/clients/codex/config.go index 0f0c284..9ddb28f 100644 --- a/internal/clients/codex/config.go +++ b/internal/clients/codex/config.go @@ -5,8 +5,6 @@ import ( "os" "path/filepath" "strconv" - - "github.com/tailscale/aperture-cli/internal/config" ) // writeConfig creates (or refreshes) the persistent CODEX_HOME directory @@ -16,11 +14,19 @@ import ( // auth.json is pre-populated so Codex's first-run login prompt is skipped. // config.toml pins the model provider to "aperture" pointing at the current // aperture gateway. +// +// The path is the legacy "/aperture/codex-home" used before the +// clients refactor, preserved so any per-home state Codex has stored under +// it continues to resolve. func writeConfig(apertureHost string) (string, error) { - codexHome, err := config.ClientConfigDir("codex") + cfgDir, err := os.UserConfigDir() if err != nil { return "", err } + codexHome := filepath.Join(cfgDir, "aperture", "codex-home") + if err := os.MkdirAll(codexHome, 0o700); err != nil { + return "", err + } auth := map[string]any{ "auth_mode": "apikey", diff --git a/internal/clients/gemini/config.go b/internal/clients/gemini/config.go index 51adee9..6010235 100644 --- a/internal/clients/gemini/config.go +++ b/internal/clients/gemini/config.go @@ -4,19 +4,22 @@ import ( "encoding/json" "os" "path/filepath" - - "github.com/tailscale/aperture-cli/internal/config" ) // writeConfig creates a persistent GEMINI_CLI_HOME whose // /.gemini/settings.json selects the auth type matching the chosen // backend (vertex-ai vs gemini-api-key). Returns the home path to hand to // the agent via the GEMINI_CLI_HOME env var. +// +// The path is the legacy "/aperture/gemini-home" used before the +// clients refactor, preserved so users' existing OAuth credentials under +// /.gemini/oauth_creds.json keep working. func writeConfig(selectedAuthType string) (string, error) { - geminiHome, err := config.ClientConfigDir("gemini") + cfgDir, err := os.UserConfigDir() if err != nil { return "", err } + geminiHome := filepath.Join(cfgDir, "aperture", "gemini-home") geminiDir := filepath.Join(geminiHome, ".gemini") if err := os.MkdirAll(geminiDir, 0o700); err != nil { return "", err diff --git a/internal/clients/gemini/gemini.go b/internal/clients/gemini/gemini.go index bff9f9f..a3bd59b 100644 --- a/internal/clients/gemini/gemini.go +++ b/internal/clients/gemini/gemini.go @@ -5,8 +5,11 @@ package gemini import ( + "fmt" + "net/url" "os/exec" "slices" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/tailscale/aperture-cli/internal/clients" @@ -135,6 +138,14 @@ func (c *Client) backendStep(g *config.Global, p config.ProviderInfo) menu.Resul } func (c *Client) launch(g *config.Global, p config.ProviderInfo, b backend) menu.Result { + // Gemini CLI 0.40+ refuses custom base URLs that aren't https:// with a + // fully-qualified domain name (it allows http only for literal + // localhost / 127.0.0.1). Aperture's default "http://ai" short hostname + // won't work — block the launch with a clear message rather than let + // Gemini fail with an authentication error after start. + if err := validateHost(g.ApertureHost); err != nil { + return errorResult(err.Error()) + } bin := clients.FindBinary(binaryName, c.CommonPaths()) if bin == "" { bin = binaryName @@ -143,16 +154,23 @@ func (c *Client) launch(g *config.Global, p config.ProviderInfo, b backend) menu if err != nil { return errorResult("Failed to write Gemini config: " + err.Error()) } + + host := strings.TrimRight(g.ApertureHost, "/") + env := map[string]string{ "GEMINI_CLI_HOME": geminiHome, } switch b.id { case "vertex": - env["GOOGLE_VERTEX_BASE_URL"] = g.ApertureHost + env["GOOGLE_VERTEX_BASE_URL"] = host env["GOOGLE_API_KEY"] = "not-needed" case "gemini": env["GEMINI_API_KEY"] = "not-needed" - env["GEMINI_BASE_URL"] = g.ApertureHost + // Gemini CLI 0.40+ reads GOOGLE_GEMINI_BASE_URL; older versions + // honored GEMINI_BASE_URL. Set both so we route correctly across + // the upgrade. + env["GEMINI_BASE_URL"] = host + env["GOOGLE_GEMINI_BASE_URL"] = host } var args []string @@ -231,6 +249,45 @@ func backendsFor(p config.ProviderInfo) []backend { return out } +// validateHost rejects aperture endpoints that Gemini CLI 0.40+ will refuse. +// The CLI's validator requires the base URL to be https:// and have a +// fully-qualified host (it treats a bare label like "ai" as unusable except +// when it is literal localhost / 127.0.0.1). +func validateHost(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return fmt.Errorf( + "Gemini CLI needs a valid Aperture endpoint URL.\n\n"+ + "Current: %q\n\n"+ + "Set an HTTPS endpoint with a fully-qualified domain name "+ + "(e.g. https://ai.example.com) in Settings → Aperture Endpoints.", + raw, + ) + } + if u.Scheme != "https" { + return fmt.Errorf( + "Gemini CLI requires an HTTPS Aperture endpoint.\n\n"+ + "Current: %q\n\n"+ + "Set an https:// endpoint (e.g. https://ai.example.com) in "+ + "Settings → Aperture Endpoints.", + raw, + ) + } + host := u.Hostname() + if !strings.Contains(host, ".") { + return fmt.Errorf( + "Gemini CLI requires a fully-qualified domain name for its "+ + "Aperture endpoint.\n\n"+ + "Current: %q\n\n"+ + "Short hostnames like %q are rejected by Gemini CLI. Use the "+ + "full FQDN (e.g. https://ai.example.com) in Settings → "+ + "Aperture Endpoints.", + raw, host, + ) + } + return nil +} + func errorResult(msg string) menu.Result { return menu.Result{Cmd: func() tea.Msg { return menu.SimpleDoneMsg{Err: errString(msg)} diff --git a/internal/clients/gemini/gemini_test.go b/internal/clients/gemini/gemini_test.go index 9d1e0ef..9825bbb 100644 --- a/internal/clients/gemini/gemini_test.go +++ b/internal/clients/gemini/gemini_test.go @@ -48,6 +48,29 @@ func TestBackendsFor_Both(t *testing.T) { } } +func TestValidateHost(t *testing.T) { + cases := []struct { + host string + wantErr bool + }{ + {"https://ai.example.com", false}, + {"https://aperture.corp.ts.net/", false}, + {"https://ai:8080", true}, // bare label + {"http://ai.example.com", true}, // not https + {"http://ai", true}, // bare label + not https + {"https://ai", true}, // bare label + {"ai.example.com", true}, // missing scheme + {"https://", true}, // missing host + {"not a url", true}, // unparseable + } + for _, c := range cases { + err := validateHost(c.host) + if (err != nil) != c.wantErr { + t.Errorf("validateHost(%q) err=%v, wantErr=%v", c.host, err, c.wantErr) + } + } +} + func TestWriteConfig_Vertex(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 6f6e147..dda0b79 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -146,9 +146,11 @@ func (m *model) endpointsMenu() *menu.Menu { }, }) } + // Hidden: "a" prompts for a new endpoint. Surfaced via the footer hint. items = append(items, menu.MenuItem{ - Label: "Add endpoint", + Label: "add", Shortcut: "a", + Hidden: true, Action: func() menu.Result { m.promptForInput("Add Endpoint:", "", func(v string) tea.Cmd { _ = m.g.UpsertEndpoint(v) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ff9ac79..9267b7c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -276,8 +276,10 @@ func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // Single-char shortcut (explicit Shortcut wins over auto-assigned // tokens so e.g. "d" on the endpoints menu always deletes). + // Hidden items are allowed: the root menu registers Settings and + // Install-agents as hidden Shortcut-only rows. for i, it := range top.Items { - if it.Hidden || it.Disabled { + if it.Disabled { continue } if it.Shortcut != "" && it.Shortcut == s { @@ -304,7 +306,13 @@ func (m *model) activate(idx int) (tea.Model, tea.Cmd) { if item.Disabled || item.Action == nil { return m, nil } - m.setCursor(idx) + // Only move the cursor onto visible rows. Hidden shortcut handlers + // (e.g. endpoints menu's "d" delete) read m.cursor() to know which + // visible row to act on — moving the cursor onto the hidden handler + // itself would strand it off-screen and break subsequent actions. + if !item.Hidden { + m.setCursor(idx) + } res := item.Action() return m.applyResult(res) } From cb5d5f216e96bb5fc9b460f1569111b1bd3a51a8 Mon Sep 17 00:00:00 2001 From: Benson Wong Date: Fri, 1 May 2026 16:05:12 +0000 Subject: [PATCH 15/15] internal/*: gofmt fixes --- internal/clients/codex/codex_test.go | 8 ++++---- internal/clients/gemini/gemini_test.go | 14 +++++++------- internal/menu/menu.go | 6 +++--- internal/tui/tui_test.go | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/clients/codex/codex_test.go b/internal/clients/codex/codex_test.go index 706c10e..ef43a4a 100644 --- a/internal/clients/codex/codex_test.go +++ b/internal/clients/codex/codex_test.go @@ -34,10 +34,10 @@ func TestFqnModels(t *testing.T) { func TestStripProviderPrefix(t *testing.T) { cases := map[string]string{ - "openai/gpt-5": "gpt-5", - "vertex/gemini-2.5-pro": "gemini-2.5-pro", - "bare-model": "bare-model", - "provider/nested/model": "nested/model", + "openai/gpt-5": "gpt-5", + "vertex/gemini-2.5-pro": "gemini-2.5-pro", + "bare-model": "bare-model", + "provider/nested/model": "nested/model", } for in, want := range cases { if got := stripProviderPrefix(in); got != want { diff --git a/internal/clients/gemini/gemini_test.go b/internal/clients/gemini/gemini_test.go index 9825bbb..89e5dec 100644 --- a/internal/clients/gemini/gemini_test.go +++ b/internal/clients/gemini/gemini_test.go @@ -55,13 +55,13 @@ func TestValidateHost(t *testing.T) { }{ {"https://ai.example.com", false}, {"https://aperture.corp.ts.net/", false}, - {"https://ai:8080", true}, // bare label - {"http://ai.example.com", true}, // not https - {"http://ai", true}, // bare label + not https - {"https://ai", true}, // bare label - {"ai.example.com", true}, // missing scheme - {"https://", true}, // missing host - {"not a url", true}, // unparseable + {"https://ai:8080", true}, // bare label + {"http://ai.example.com", true}, // not https + {"http://ai", true}, // bare label + not https + {"https://ai", true}, // bare label + {"ai.example.com", true}, // missing scheme + {"https://", true}, // missing host + {"not a url", true}, // unparseable } for _, c := range cases { err := validateHost(c.host) diff --git a/internal/menu/menu.go b/internal/menu/menu.go index b791b6c..3d1b135 100644 --- a/internal/menu/menu.go +++ b/internal/menu/menu.go @@ -48,9 +48,9 @@ type MenuItem struct { // Menu is a list of selectable items plus optional title and footer hint. type Menu struct { - Title string - Items []MenuItem - Hint string + Title string + Items []MenuItem + Hint string // OnBack, when non-nil, overrides the default "pop stack one level" // behavior on Esc. Returning a nil tea.Cmd simply stays on this menu. OnBack func() tea.Cmd diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index c6f2a66..6669897 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -35,7 +35,7 @@ func (c *fakeClient) Menu(*config.Global) menu.MenuItem { Action: func() menu.Result { return menu.Result{Next: c.menuActions} }, } } -func (c *fakeClient) Replay(*config.Global) tea.Cmd { return c.replayCmd } +func (c *fakeClient) Replay(*config.Global) tea.Cmd { return c.replayCmd } func (c *fakeClient) QuickSelectLabel(*config.Global) string { return c.quickLabel } // withFakeClients swaps the TUI's client registry for the duration of the test.