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..5489706 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -5,18 +5,29 @@ import ( "fmt" "log/slog" "os" + "os/exec" + "path/filepath" + "runtime" "runtime/debug" + "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 ( 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 +38,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 +71,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() @@ -68,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, *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..ef43a4a --- /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..9ddb28f --- /dev/null +++ b/internal/clients/codex/config.go @@ -0,0 +1,55 @@ +package codex + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" +) + +// 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. +// +// 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) { + 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", + "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..6010235 --- /dev/null +++ b/internal/clients/gemini/config.go @@ -0,0 +1,42 @@ +package gemini + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// 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) { + 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 + } + 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..a3bd59b --- /dev/null +++ b/internal/clients/gemini/gemini.go @@ -0,0 +1,299 @@ +// 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 ( + "fmt" + "net/url" + "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 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 { + // 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 + } + geminiHome, err := writeConfig(b.authType) + 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"] = host + env["GOOGLE_API_KEY"] = "not-needed" + case "gemini": + env["GEMINI_API_KEY"] = "not-needed" + // 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 + 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 +} + +// 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)} + }} +} + +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..89e5dec --- /dev/null +++ b/internal/clients/gemini/gemini_test.go @@ -0,0 +1,101 @@ +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 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) + 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..45fd086 --- /dev/null +++ b/internal/clients/opencode/opencode.go @@ -0,0 +1,203 @@ +// 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 goes straight from provider selection to launch; model +// selection happens inside OpenCode itself. +package opencode + +import ( + "os" + "os/exec" + "path/filepath" + + 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.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.launch(g, p) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a provider for " + name + ":", + Items: items, + }} +} + +func (c *Client) launch(g *config.Global, p config.ProviderInfo) 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. 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, + }) + + 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 + } + 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) + return name + " via " + prov.DisplayName() +} + +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 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/clients/opencode/sdk.go b/internal/clients/opencode/sdk.go new file mode 100644 index 0000000..6b17fd8 --- /dev/null +++ b/internal/clients/opencode/sdk.go @@ -0,0 +1,121 @@ +package opencode + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/tailscale/aperture-cli/internal/config" +) + +type opencodeConfig struct { + Schema string `json:"$schema,omitempty"` + Provider map[string]opencodeProvider `json:"provider,omitempty"` +} + +type opencodeProvider struct { + NPM string `json:"npm,omitempty"` + 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"` +} + +// 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{ + "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["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 + // 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 compat["bedrock_model_invoke"] || compat["bedrock_converse"]: + return "@ai-sdk/amazon-bedrock", map[string]string{ + "region": "us-east-1", + "endpoint": apertureHost + "/bedrock", + } + case compat["gemini_generate_content"]: + return "@ai-sdk/google", map[string]string{ + "baseURL": apertureHost + "/v1beta", + "apiKey": "not-required", + } + } + return "", nil +} + +// 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)) + for _, m := range p.Models { + 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, + Whitelist: whitelist, + }, + }, + } + + data, err := json.Marshal(cfg) + if err != nil { + return "", nil, err + } + + home, err := os.UserHomeDir() + if err != nil { + return "", nil, err + } + configDir := filepath.Join(home, ".opencode") + if err := os.MkdirAll(configDir, 0o700); err != nil { + return "", nil, err + } + path := filepath.Join(configDir, "tmp_aperture_config.json") + if err := os.WriteFile(path, data, 0o600); err != nil { + return "", nil, err + } + 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..3d1b135 --- /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 485d0f8..0000000 --- a/internal/profiles/claude_code.go +++ /dev/null @@ -1,221 +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 -} - -// 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 800c872..0000000 --- a/internal/profiles/codex.go +++ /dev/null @@ -1,97 +0,0 @@ -package profiles - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" -) - -// 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{"--full-auto"} -} - -func (c *CodexProfile) RequiredCompat(b Backend) []string { - switch b.Type { - case BackendOpenAI: - return []string{"openai_responses"} - default: - return nil - } -} - -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(_ 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 - } - 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/opencode.go b/internal/profiles/opencode.go deleted file mode 100644 index 959572f..0000000 --- a/internal/profiles/opencode.go +++ /dev/null @@ -1,175 +0,0 @@ -package profiles - -import ( - "encoding/json" - "fmt" - "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")) - } -} - -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"}, - } -} - -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 - } -} - -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. - 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) - } -} - -type opencodeConfig struct { - Schema string `json:"$schema,omitempty"` - Provider map[string]opencodeProvider `json:"provider,omitempty"` -} - -type opencodeProvider struct { - Options map[string]string `json:"options,omitempty"` -} - -func (o *OpenCodeProfile) WriteConfig(apertureHost string, b Backend) (string, string, func(), error) { - var providerKey string - var options map[string]string - - switch b.Type { - case BackendAnthropic: - providerKey = "anthropic" - options = map[string]string{ - "apiKey": "{env:ANTHROPIC_AUTH_TOKEN}", - "baseURL": "{env:ANTHROPIC_BASE_URL}", - } - case BackendBedrock: - providerKey = "amazon-bedrock" - options = 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 BackendOpenAI: - providerKey = "openai" - options = map[string]string{ - "apiKey": "{env:OPENAI_API_KEY}", - "baseURL": "{env:OPENAI_BASE_URL}", - } - default: - return "", "", nil, fmt.Errorf("unsupported backend %q for OpenCode", b.Type) - } - - provider := opencodeProvider{Options: options} - - cfg := opencodeConfig{ - Schema: "https://opencode.ai/config.json", - Provider: map[string]opencodeProvider{ - providerKey: provider, - }, - } - - data, err := json.Marshal(cfg) - if err != nil { - return "", "", nil, err - } - - home, err := os.UserHomeDir() - if err != nil { - return "", "", nil, err - } - configDir := filepath.Join(home, ".opencode") - if err := os.MkdirAll(configDir, 0o700); err != nil { - return "", "", nil, err - } - path := filepath.Join(configDir, "tmp_aperture_config.json") - if err := os.WriteFile(path, data, 0o600); err != nil { - return "", "", nil, err - } - return "OPENCODE_CONFIG", path, func() { os.Remove(path) }, nil -} diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index 18b18f9..f58a27e 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -1,336 +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) -} - -// 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"` -} - -// 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 -} - -// 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 -} - -// 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"` -} - -// 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 acc7e1b..0000000 --- a/internal/profiles/profiles_test.go +++ /dev/null @@ -1,783 +0,0 @@ -package profiles_test - -import ( - "encoding/json" - "os" - "path/filepath" - "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_Env_Anthropic(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") - } -} - -func TestLauncher_OpenCode_Env_Bedrock(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") - } - if got := env["AWS_REGION"]; got != "us-east-1" { - t.Errorf("AWS_REGION = %q, want %q", got, "us-east-1") - } -} - -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_") - } - if got := env["GOOGLE_CLOUD_LOCATION"]; got != "_aperture_auto_vertex_region_" { - t.Errorf("GOOGLE_CLOUD_LOCATION = %q, want %q", got, "_aperture_auto_vertex_region_") - } -} - -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) - } - if got := env["OPENAI_BASE_URL"]; got != testHost+"/v1" { - t.Errorf("OPENAI_BASE_URL = %q, want %q", got, testHost+"/v1") - } -} - -func TestLauncher_OpenCode_WriteConfig(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) - if !ok { - t.Fatal("OpenCodeProfile does not implement ConfigWriter") - } - - tests := []struct { - name string - backend profiles.Backend - wantKey string - wantOptions map[string]string - }{ - { - name: "anthropic", - backend: profiles.Backend{Type: profiles.BackendAnthropic}, - wantKey: "anthropic", - wantOptions: map[string]string{ - "apiKey": "{env:ANTHROPIC_AUTH_TOKEN}", - "baseURL": "{env:ANTHROPIC_BASE_URL}", - }, - }, - { - name: "bedrock", - backend: profiles.Backend{Type: profiles.BackendBedrock}, - wantKey: "amazon-bedrock", - wantOptions: map[string]string{ - "region": "us-east-1", - "endpoint": testHost + "/bedrock", - }, - }, - { - name: "vertex", - backend: profiles.Backend{Type: profiles.BackendVertex}, - wantKey: "google-vertex", - wantOptions: map[string]string{ - "project": "_aperture_auto_vertex_project_id_", - "location": "_aperture_auto_vertex_region_", - "baseURL": testHost + "/v1", - }, - }, - { - name: "openai", - backend: profiles.Backend{Type: profiles.BackendOpenAI}, - wantKey: "openai", - wantOptions: map[string]string{ - "apiKey": "{env:OPENAI_API_KEY}", - "baseURL": "{env:OPENAI_BASE_URL}", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, configPath, cleanup, err := cw.WriteConfig(testHost, tt.backend) - if err != nil { - t.Fatalf("WriteConfig returned error: %v", err) - } - - // 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 { - t.Fatalf("config file is not valid JSON: %v", err) - } - providerRaw, ok := raw["provider"] - if !ok { - t.Fatal("config missing 'provider' key") - } - var providers map[string]struct { - Options map[string]string `json:"options"` - } - 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) - } - for k, want := range tt.wantOptions { - if got := prov.Options[k]; got != want { - t.Errorf("options[%q] = %q, want %q", k, got, want) - } - } - - // cleanup removes the file - 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] != "--full-auto" { - t.Errorf("YoloArgs() = %v, want [--full-auto]", args) - } -} - -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_OpenCodeBedrock(t *testing.T) { - p := &profiles.OpenCodeProfile{} - keys := p.RequiredCompat(profiles.Backend{Type: profiles.BackendBedrock}) - if len(keys) == 0 { - t.Fatal("expected at least one compat key for OpenCode+Bedrock") - } - - // Verify that a provider with bedrock_converse satisfies the requirement. - 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 - } - } - if !found { - t.Error("expected Bedrock backend to be available with bedrock_converse provider") - } -} - -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_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..dda0b79 --- /dev/null +++ b/internal/tui/menus.go @@ -0,0 +1,347 @@ +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)} + }, + }) + } + // Hidden: "a" prompts for a new endpoint. Surfaced via the footer hint. + items = append(items, menu.MenuItem{ + Label: "add", + Shortcut: "a", + Hidden: true, + 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 c0dab91..9267b7c 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,32 +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 - stepSelectAgent // choose profile - stepSelectBackend // choose provider - 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 ( @@ -45,98 +42,58 @@ 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 +// 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 combos for the last-used shortcut - lastCombo *profiles.Combo - - // all known profiles; installedProfiles is the subset on PATH - allProfiles []profiles.Profile - installedProfiles []profiles.Profile - - step step - agentCursor int - backendItems []profiles.Backend - backendCursor int + g *config.Global + buildVersion string - chosenProfile profiles.Profile + step step - // preflight state - preflightChecking bool - providers []profiles.ProviderInfo - preflightErr string + // Terminal dimensions, refreshed on tea.WindowSizeMsg. Zero until the + // first message arrives. + width, height int - // endpointsFromSetup is true when stepEndpoints was reached via preflight failure. - endpointsFromSetup bool + // 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 - // settings step - settingsCursor int + // Input step state. + inputTitle string + inputPrompt string + inputValue string + inputOnSave func(value string) tea.Cmd - // endpoints submenu step - endpointsCursor int + // Error screen state. + errMsg string - // install agents submenu step - installAgentsCursor int - - // uninstall submenu step - uninstallCursor int - - // add-location step - addLocationInput 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, debug bool) tea.Model { - mgr := profiles.NewManager() - - m := model{ - apertureHost: apertureHost, - settings: settings, - state: state, - manager: mgr, - allProfiles: mgr.AllProfiles(), - installedProfiles: mgr.InstalledProfiles(), - debug: debug, - step: stepPreflight, - 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 +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} @@ -146,131 +103,77 @@ 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{ combo profiles.Combo } -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 tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + 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() - // 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 - } - } - // 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]} } - } - m.step = stepSelectAgent + 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.execCombo(msg.combo) + 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 = stepSelectAgent - m.agentCursor = 0 + 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.lastCombo = 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 - m.agentCursor = 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 agent - // 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 } - m.step = stepSelectAgent - m.agentCursor = 0 - return m, tea.ClearScreen + m.popOne() + return m, nil case tea.KeyMsg: switch m.step { @@ -278,912 +181,502 @@ 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 stepSelectAgent: - return m.updateSelectAgent(msg) - - case stepSelectBackend: - return m.updateSelectBackend(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 = stepSelectBackend + default: + m.step = stepMenu + return m, nil } + case stepInput: + return m.updateInput(msg) + case stepMenu: + return m.updateMenu(msg) } } return m, nil } -// 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 -} - -// 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) 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 - profileCount := len(m.installedProfiles) - hasUninstalled := len(m.uninstalledProfiles()) > 0 - // +1 for the Settings row - totalRows := profileCount + 1 - if hasLast { - totalRows++ - } - if hasUninstalled { - totalRows++ +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 "q": + // "q" quits from the root only; on sub-menus it pops. + if len(m.stack) <= 1 { + return m, tea.Quit + } + m.popOne() + return m, tea.ClearScreen - case "i": - if hasUninstalled { - m.step = stepInstallAgents - m.installAgentsCursor = 0 + case "esc": + if top.OnBack != nil { + if cmd := top.OnBack(); cmd != nil { + return m, cmd + } + return m, nil + } + if len(m.stack) <= 1 { + // Root menu ignores Esc. return m, nil } + m.popOne() + return m, tea.ClearScreen case "up", "k": - if m.agentCursor > 0 { - m.agentCursor-- + visible, _, _ := m.menuLayout(top) + if p := visiblePos(visible, cursor); p > 0 { + m.setCursor(visible[p-1]) } + return m, nil case "down", "j": - if m.agentCursor < totalRows-1 { - m.agentCursor++ + 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 case "enter": - return m.confirmAgentSelection() + return m.activate(cursor) default: - n, err := strconv.Atoi(msg.String()) - if err == nil { - if hasLast && n == 0 { - combo := *m.lastCombo - return m, m.execCombo(combo) + s := msg.String() + 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). + // 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.Disabled { + continue + } + if it.Shortcut != "" && it.Shortcut == s { + return m.activate(i) } - idx := n - 1 - if idx >= 0 && idx < profileCount { - m.agentCursor = idx - if hasLast { - m.agentCursor = n - } - return m.confirmAgentSelection() + } + // Auto-assigned or explicit-Digit token. + tokens := assignTokens(top.Items) + for i, tok := range tokens { + if tok != "" && tok == s { + return m.activate(i) } } } 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) - } - - profileIdx := m.agentCursor - if hasLast { - profileIdx = m.agentCursor - 1 - } - - // 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 - } - 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 - } - } - combo := profiles.Combo{Profile: chosen, Backend: b} - return m, m.execCombo(combo) - } - m.backendCursor = 0 - m.step = stepSelectBackend +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 } - - // "Install agents" row (right after installed profiles). - nextIdx := len(m.installedProfiles) - if hasUninstalled && profileIdx == nextIdx { - m.step = stepInstallAgents - m.installAgentsCursor = 0 + item := top.Items[idx] + if item.Disabled || item.Action == nil { return m, nil } - if hasUninstalled { - nextIdx++ - } - - // Settings row. - if profileIdx == nextIdx { - m.step = stepSettings - m.settingsCursor = 0 - return m, nil + // 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) } - - return m, nil + res := item.Action() + return m.applyResult(res) } -func (m model) updateInstall(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "q": +func (m *model) applyResult(res menu.Result) (tea.Model, tea.Cmd) { + switch { + case res.Quit: return m, tea.Quit - case "esc", "n", "enter": - m.step = stepSelectAgent + 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) + } + 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 "y": - return m, m.runInstall() + case res.Cmd != nil: + return m, res.Cmd } return m, nil } -func (m model) updateInstallAgents(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - uninstalled := m.uninstalledProfiles() - total := len(uninstalled) - +func (m *model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { - case "ctrl+c", "q": + case "ctrl+c": return m, tea.Quit case "esc": - m.step = stepSelectAgent - return m, tea.ClearScreen - case "up", "k": - if m.installAgentsCursor > 0 { - m.installAgentsCursor-- + m.step = stepMenu + m.inputValue = "" + return m, nil + case "enter": + v := strings.TrimSpace(m.inputValue) + if v == "" { + return m, nil } - case "down", "j": - if m.installAgentsCursor < total-1 { - m.installAgentsCursor++ + fn := m.inputOnSave + m.step = stepMenu + m.inputValue = "" + if fn != nil { + return m, fn(v) } - case "enter": - if m.installAgentsCursor < total { - m.chosenProfile = uninstalled[m.installAgentsCursor] - m.step = stepInstall + return m, nil + case "backspace": + if len(m.inputValue) > 0 { + m.inputValue = m.inputValue[:len(m.inputValue)-1] } 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 - } + s := msg.String() + if len(s) == 1 { + m.inputValue += s } + return m, nil } - 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} } +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") } - 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 + 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() } - 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} - }) + return "" } -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 +func (m *model) viewMenu() string { + top := m.top() + if top == nil { + return "" + } + var sb strings.Builder + if header := m.menuHeader(top); header != "" { + sb.WriteString(header) + } + if top.Title != "" { + sb.WriteString(titleStyle.Render(top.Title)) + sb.WriteString("\n") + } + cursor := m.cursor() + 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 = " " + } + plain := fmt.Sprintf(" [%s] %s", tok, it.Label) + if it.Description != "" { + plain += " " + it.Description + } + styled := fmt.Sprintf(" [%s] %s", tok, it.Label) + if it.Description != "" { + styled += " " + dimStyle.Render(it.Description) + } + if it.Disabled { + styled = dimStyle.Render(styled) + } else if i == cursor { + styled = selectedStyle.Render(styled) + } + 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") } - 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 + } else { + for _, i := range visible { + sb.WriteString(styleds[i]) + sb.WriteString("\n") + if top.Items[i].Digit == menu.DigitZero { + sb.WriteString("\n") } } } - 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 + sb.WriteString("\n") + if top.Hint != "" { + sb.WriteString(dimStyle.Render(top.Hint)) + sb.WriteString("\n") } - fn := uninst.Uninstall() - return func() tea.Msg { - return uninstallDoneMsg{err: fn()} + 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) updateSelectBackend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "esc": - m.step = stepSelectAgent - 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++ +// 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 } - - case "enter": - return m.checkAndExecSelectedBackend() - - 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() - } + if it.Digit == menu.DigitZero { + hasZero = true } + visible = append(visible, i) } - return m, nil -} - -// 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" + if m.width < 80 || len(visible) < 10 || hasZero { + return visible, false, 0 } - 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 = stepSelectAgent - return m, tea.ClearScreen - - case "up", "k": - if m.settingsCursor > 0 { - m.settingsCursor-- + tokens := assignTokens(top.Items) + maxW := 0 + for _, i := range visible { + it := top.Items[i] + tok := tokens[i] + if tok == "" { + tok = " " } - - case "down", "j": - if m.settingsCursor < total-1 { - m.settingsCursor++ + w := len(" [] ") + len(tok) + len(it.Label) + if it.Description != "" { + w += 2 + len(it.Description) } - - 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() - } + if w > maxW { + maxW = w } } - 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) - return m, nil + if maxW*2+4 > m.width { + return visible, false, 0 } - return m, nil + return visible, true, (len(visible) + 1) / 2 } -// 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)" +// 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 } - rows = append(rows, label) } - return rows + return -1 } -func (m model) updateEndpoints(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - total := len(m.settings.Endpoints) - - switch msg.String() { - case "ctrl+c": - 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() - } - } +// 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)) } - 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) - } - } - 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) + for c := 'a'; c <= 'z'; c++ { + out = append(out, string(c)) } - return m, nil -} + for c := 'A'; c <= 'Z'; c++ { + out = append(out, string(c)) + } + return out +}() -func (m model) updateAddLocation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - case "esc": - m.step = stepEndpoints - return m, nil - case "enter": - loc := strings.TrimSpace(m.addLocationInput) - if loc == "" { - return m, nil - } - m.settings = upsertLocation(m.settings, loc) - _ = profiles.SaveSettings(m.settings) - m.step = stepEndpoints - m.endpointsCursor = 0 - return m, nil - case "backspace": - if len(m.addLocationInput) > 0 { - m.addLocationInput = m.addLocationInput[:len(m.addLocationInput)-1] +// 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 } - default: - if len(msg.String()) == 1 { - m.addLocationInput += msg.String() + if it.Digit > 0 { + reserved[fmt.Sprintf("%d", it.Digit)] = true } } - return m, nil -} - -// upsertLocation ensures loc is in settings.Endpoints without duplicates. -// If it already exists it stays in place; otherwise it is appended. -func upsertLocation(s profiles.Settings, loc string) profiles.Settings { - for _, ep := range s.Endpoints { - if ep.URL == loc { - return s + pool := make([]string, 0, len(autoTokens)) + for _, t := range autoTokens { + if !reserved[t] { + pool = append(pool, t) } } - s.Endpoints = append(s.Endpoints, profiles.Endpoint{URL: loc}) - return s -} - -func (m model) checkAndExecSelectedBackend() (model, tea.Cmd) { - if m.backendCursor < 0 || m.backendCursor >= len(m.backendItems) { - return m, nil - } - b := m.backendItems[m.backendCursor] - 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 + 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++ + } } } - combo := profiles.Combo{Profile: m.chosenProfile, Backend: b} - return m, m.execCombo(combo) + return tokens } -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), - }) - host := m.apertureHost - return func() tea.Msg { - return launchDoneMsg{err: launcher.Launch(host)} +// 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" } - - 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, m.providers) { - env[k] = v + 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 header + "\n" } + return "" +} - binary := profiles.FindBinary(combo.Profile) - if binary == "" { - binary = combo.Profile.BinaryName() - } - - _ = profiles.SaveState(profiles.StateFile{ - LastProfileName: combo.Profile.Name(), - LastBackendType: string(combo.Backend.Type), - }) +// --- Stack helpers --- - envPairs := os.Environ() - for k, v := range env { - envPairs = append(envPairs, k+"="+v) +func (m *model) top() *menu.Menu { + if len(m.stack) == 0 { + return nil } + return m.stack[len(m.stack)-1] +} - var configCleanup func() - var configEnvKey, configPath string - 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) - } +func (m *model) cursor() int { + if len(m.cursors) == 0 { + return 0 } + return m.cursors[len(m.cursors)-1] +} - 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) - } +func (m *model) setCursor(c int) { + if len(m.cursors) == 0 { + return } + m.cursors[len(m.cursors)-1] = c +} - var extraArgs []string - if m.settings.YoloMode { - if yp, ok := combo.Profile.(profiles.YoloProfile); ok { - extraArgs = yp.YoloArgs() - } +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 m.debug && len(extraArgs) > 0 { - fmt.Fprintf(os.Stderr, "[debug] args: %v\r\n", extraArgs) +func (m *model) popToRoot() { + if len(m.stack) > 1 { + m.stack = m.stack[:1] + m.cursors = m.cursors[:1] } - - 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} - }) } -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 stepSelectAgent: - 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") - - 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 { - sb.WriteString(selectedStyle.Render(label)) - } else { - sb.WriteString(label) - } - sb.WriteString("\n") - } - - 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) - } - sb.WriteString("\n") - nextCursor++ - } - - // Settings row - settingsLabel := " [s] Settings" - if m.agentCursor == nextCursor { - sb.WriteString(selectedStyle.Render(settingsLabel)) - } else { - sb.WriteString(settingsLabel) - } - sb.WriteString("\n") - sb.WriteString(" [q] Quit") - sb.WriteString("\n") - - sb.WriteString("\n") - if hasLast { - sb.WriteString(dimStyle.Render("Selection (default: 0): ")) - } else { - sb.WriteString(dimStyle.Render("Selection: ")) - } - - 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("Choose a Provider:")) - 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 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")) +func (m *model) resetStack(root *menu.Menu) { + m.stack = []*menu.Menu{root} + m.cursors = []int{0} +} - 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")) +// --- Input step helpers --- - 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 new file mode 100644 index 0000000..6669897 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,230 @@ +package tui + +import ( + "strings" + "testing" + + 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" +) + +// 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 (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 (c *fakeClient) Uninstall() clients.UninstallPlan { + return clients.UninstallPlan{Hint: "uninstall " + c.name} +} +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 (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 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++ + } + } + if visible != 2 { + t.Errorf("visible items = %d, want 2", visible) + } +} + +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", + } + 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 + } + } + if first.Digit != menu.DigitZero { + t.Errorf("first visible Digit = %d, want DigitZero", first.Digit) + } + if !strings.Contains(first.Label, "Quick select") { + t.Errorf("first visible Label = %q", first.Label) + } + + // Invoking the action should run the replay cmd. + res := first.Action() + if res.Cmd == nil { + t.Fatal("quick select action returned nil Cmd") + } + _ = res.Cmd() // run it + if !replayed { + t.Error("replay cmd was not invoked") + } +} + +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 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) + } + + // 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 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() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + + g := &config.Global{} + m := &model{g: g, step: stepMenu} + m.resetStack(m.settingsMenu()) + + // 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) + } +}