From 58cadb1041785f54c1ce2880488078f007cd7de0 Mon Sep 17 00:00:00 2001 From: Luke Kosewski Date: Fri, 10 Apr 2026 06:56:05 +0000 Subject: [PATCH 1/2] profiles,tui: Add Claude Cowork (Desktop) profile Add support for configuring and launching Claude Cowork / Claude Code GUI as a desktop application profile. Unlike CLI profiles, Cowork writes persistent platform-specific gateway configuration and launches the app rather than exec'ing a binary. Install: writes three gateway config values (inferenceProvider, inferenceGatewayApiKey, inferenceGatewayBaseUrl) to the macOS plist (~/Library/Preferences/com.anthropic.claude.plist) or Windows registry (HKCU\SOFTWARE\Policies\Claude), then opens the installer download in the user's browser. Launch: reads back the stored gateway URL, re-writes config if the active Aperture endpoint has changed, then starts the app (open -a Claude on macOS, cmd /c start claude:// on Windows). New interfaces: - Launcher: for profiles that launch a desktop app (returns immediately) - HostAwareInstaller: for install steps that need the aperture host URL Also fixes isExecutable() on Windows where Unix permission bits are always zero, causing CommonPaths detection to fail for all profiles. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/profiles/claude_desktop.go | 70 +++++++++++++++++ internal/profiles/claude_desktop_darwin.go | 48 ++++++++++++ internal/profiles/claude_desktop_other.go | 30 +++++++ internal/profiles/claude_desktop_windows.go | 86 +++++++++++++++++++++ internal/profiles/profiles.go | 44 +++++++++-- internal/profiles/profiles_test.go | 45 +++++++++++ internal/tui/tui.go | 48 +++++++++++- 7 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 internal/profiles/claude_desktop.go create mode 100644 internal/profiles/claude_desktop_darwin.go create mode 100644 internal/profiles/claude_desktop_other.go create mode 100644 internal/profiles/claude_desktop_windows.go diff --git a/internal/profiles/claude_desktop.go b/internal/profiles/claude_desktop.go new file mode 100644 index 0000000..95ac94f --- /dev/null +++ b/internal/profiles/claude_desktop.go @@ -0,0 +1,70 @@ +package profiles + +import ( + "os/exec" + "strings" +) + +// ClaudeDesktopProfile implements Profile for the Claude Cowork application +// (Claude Code GUI). During install, it writes platform-specific gateway +// configuration (macOS plist / Windows registry) and downloads the installer. +// On launch, it re-checks the configuration and starts the desktop app. +type ClaudeDesktopProfile struct{} + +func (c *ClaudeDesktopProfile) Name() string { return "Claude Cowork" } +func (c *ClaudeDesktopProfile) BinaryName() string { return platformBinaryName() } +func (c *ClaudeDesktopProfile) CommonPaths() []string { return platformCommonPaths() } + +func (c *ClaudeDesktopProfile) SupportedBackends() []Backend { + return []Backend{ + {Type: BackendAnthropic, DisplayName: "Anthropic API"}, + } +} + +func (c *ClaudeDesktopProfile) RequiredCompat(b Backend) []string { + switch b.Type { + case BackendAnthropic: + return []string{"anthropic_messages"} + default: + return nil + } +} + +func (c *ClaudeDesktopProfile) InstallHint() string { return platformInstallHint() } + +// RunInstall writes the gateway configuration and returns a command that +// downloads and runs the installer. The TUI executes this with terminal +// takeover so the user sees download progress. +func (c *ClaudeDesktopProfile) RunInstall(apertureHost string) (*exec.Cmd, error) { + if err := platformConfigure(GatewayURL(apertureHost)); err != nil { + return nil, err + } + return platformInstallCmd(), nil +} + +// Launch checks whether the gateway configuration matches the current aperture +// host, updates it if needed, and starts the desktop app. +func (c *ClaudeDesktopProfile) Launch(apertureHost string) error { + wantURL := GatewayURL(apertureHost) + if currentURL := platformReadGatewayURL(); currentURL != wantURL { + if err := platformConfigure(wantURL); err != nil { + return err + } + } + return platformLaunch() +} + +// Env is not used for desktop app profiles but satisfies the Profile interface. +func (c *ClaudeDesktopProfile) Env(_ string, _ Backend) (map[string]string, error) { + return nil, nil +} + +// GatewayURL normalizes the aperture host for Claude Cowork's gateway config. +// Claude Cowork requires HTTPS and no trailing slash. +func GatewayURL(apertureHost string) string { + u := strings.Replace(apertureHost, "http://", "https://", 1) + if !strings.HasPrefix(u, "https://") { + u = "https://" + u + } + return strings.TrimRight(u, "/") +} diff --git a/internal/profiles/claude_desktop_darwin.go b/internal/profiles/claude_desktop_darwin.go new file mode 100644 index 0000000..8668e21 --- /dev/null +++ b/internal/profiles/claude_desktop_darwin.go @@ -0,0 +1,48 @@ +package profiles + +import ( + "fmt" + "os/exec" + "strings" +) + +func platformBinaryName() string { return "Claude" } + +func platformCommonPaths() []string { + return []string{"/Applications/Claude.app/Contents/MacOS/Claude"} +} + +func platformInstallHint() string { + return "Opens the Claude download page in your browser.\nInstall the app, then come back here to launch it." +} + +func platformConfigure(baseURL string) error { + domain := "com.anthropic.claude" + entries := [][2]string{ + {"inferenceProvider", "gateway"}, + {"inferenceGatewayApiKey", "-"}, + {"inferenceGatewayBaseUrl", baseURL}, + } + for _, e := range entries { + if err := exec.Command("defaults", "write", domain, e[0], "-string", e[1]).Run(); err != nil { + return fmt.Errorf("defaults write %s: %w", e[0], err) + } + } + return nil +} + +func platformReadGatewayURL() string { + out, err := exec.Command("defaults", "read", "com.anthropic.claude", "inferenceGatewayBaseUrl").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func platformInstallCmd() *exec.Cmd { + return exec.Command("open", "https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect?utm_source=aperture_cli") +} + +func platformLaunch() error { + return exec.Command("open", "-a", "Claude").Run() +} diff --git a/internal/profiles/claude_desktop_other.go b/internal/profiles/claude_desktop_other.go new file mode 100644 index 0000000..c05ba21 --- /dev/null +++ b/internal/profiles/claude_desktop_other.go @@ -0,0 +1,30 @@ +//go:build !darwin && !windows + +package profiles + +import ( + "fmt" + "os/exec" +) + +// On unsupported platforms, return a binary name that won't be found so the +// profile doesn't appear as installed in the TUI. +func platformBinaryName() string { return "claude-desktop-not-available" } + +func platformCommonPaths() []string { return nil } + +func platformInstallHint() string { return "" } + +func platformConfigure(_ string) error { + return fmt.Errorf("Claude Cowork configuration is only supported on macOS and Windows") +} + +func platformReadGatewayURL() string { return "" } + +func platformInstallCmd() *exec.Cmd { + return exec.Command("echo", "Claude Cowork is only supported on macOS and Windows") +} + +func platformLaunch() error { + return fmt.Errorf("Claude Cowork is only supported on macOS and Windows") +} diff --git a/internal/profiles/claude_desktop_windows.go b/internal/profiles/claude_desktop_windows.go new file mode 100644 index 0000000..848bb46 --- /dev/null +++ b/internal/profiles/claude_desktop_windows.go @@ -0,0 +1,86 @@ +package profiles + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +func platformBinaryName() string { return "Claude.exe" } + +func platformCommonPaths() []string { + // MSIX install: query the package install location via PowerShell. + out, err := exec.Command("powershell", "-Command", + `(Get-AppxPackage -Name "Claude" | Select-Object -First 1).InstallLocation`).Output() + if err == nil { + loc := strings.TrimSpace(string(out)) + if loc != "" { + p := filepath.Join(loc, "app", "Claude.exe") + if _, err := os.Stat(p); err == nil { + return []string{p} + } + } + } + + // Squirrel install fallback. + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + return nil + } + return []string{ + filepath.Join(localAppData, "Programs", "claude-desktop", "Claude.exe"), + } +} + +func platformInstallHint() string { + return "Opens the Claude download page in your browser.\nInstall the app, then come back here to launch it." +} + +func platformConfigure(baseURL string) error { + regPath := `HKCU\SOFTWARE\Policies\Claude` + entries := [][2]string{ + {"inferenceProvider", "gateway"}, + {"inferenceGatewayApiKey", "-"}, + {"inferenceGatewayBaseUrl", baseURL}, + } + for _, e := range entries { + cmd := exec.Command("reg", "add", regPath, "/v", e[0], "/t", "REG_SZ", "/d", e[1], "/f") + if err := cmd.Run(); err != nil { + return fmt.Errorf("reg add %s: %w", e[0], err) + } + } + return nil +} + +func platformReadGatewayURL() string { + out, err := exec.Command("reg", "query", `HKCU\SOFTWARE\Policies\Claude`, "/v", "inferenceGatewayBaseUrl").Output() + if err != nil { + return "" + } + // reg query output: " inferenceGatewayBaseUrl REG_SZ https://..." + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, "inferenceGatewayBaseUrl") { + parts := strings.Fields(line) + if len(parts) >= 3 { + return parts[len(parts)-1] + } + } + } + return "" +} + +func platformInstallCmd() *exec.Cmd { + url := "https://claude.ai/api/desktop/win32/x64/setup/latest/redirect?utm_source=aperture_cli" + if runtime.GOARCH == "arm64" { + url = "https://claude.ai/api/desktop/win32/arm64/setup/latest/redirect?utm_source=aperture_cli" + } + return exec.Command("cmd", "/c", "start", "", url) +} + +func platformLaunch() error { + return exec.Command("cmd", "/c", "start", "", "claude://").Run() +} diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index 0b9810e..e251a80 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -5,6 +5,8 @@ import ( "os" "os/exec" "path/filepath" + "runtime" + "strings" ) // BackendType identifies the upstream LLM provider. @@ -80,14 +82,16 @@ type Manager struct { // NewManager returns a Manager with all built-in profiles registered. func NewManager() *Manager { - return &Manager{ - profiles: []Profile{ - &ClaudeCodeProfile{}, - &GeminiCLIProfile{}, - &OpenCodeProfile{}, - &CodexProfile{}, - }, + 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 @@ -168,8 +172,16 @@ func isExecutable(path string) bool { 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.IsDir() && info.Mode()&0o111 != 0 + return info.Mode()&0o111 != 0 } // Installer is implemented by profiles that can provide installation @@ -192,6 +204,22 @@ type Uninstaller interface { 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 diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go index a5e8f5e..3273ae7 100644 --- a/internal/profiles/profiles_test.go +++ b/internal/profiles/profiles_test.go @@ -703,6 +703,51 @@ func TestLauncher_ClaudeCode_Check_EmptyEnv(t *testing.T) { } } +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() { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 99cec51..10eddfc 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -169,9 +169,9 @@ func runPreflight(host string) tea.Cmd { 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) { switch msg := msg.(type) { case preflightResult: @@ -258,6 +258,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.agentCursor = 0 return m, runPreflight(m.apertureHost) + 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() + m.step = stepError + return m, nil + } + m.step = stepSelectAgent + m.agentCursor = 0 + return m, tea.ClearScreen + case tea.KeyMsg: switch m.step { case stepPreflight: @@ -508,6 +521,20 @@ func (m model) updateInstallAgents(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m model) runInstall() tea.Cmd { + // Host-aware installers write platform config and return a download command. + if hai, ok := m.chosenProfile.(profiles.HostAwareInstaller); ok { + cmd, err := hai.RunInstall(m.apertureHost) + if err != nil { + return func() tea.Msg { return installDoneMsg{err: err} } + } + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return tea.ExecProcess(cmd, func(err error) tea.Msg { + return installDoneMsg{err: err} + }) + } + inst, ok := m.chosenProfile.(profiles.Installer) if !ok { return nil @@ -833,6 +860,19 @@ func (m model) checkAndExecSelectedBackend() (model, tea.Cmd) { } 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)} + } + } + env, err := combo.Profile.Env(m.apertureHost, combo.Backend) if err != nil { return tea.Quit @@ -1099,7 +1139,11 @@ func (m model) View() string { sb.WriteString(titleStyle.Render("Install " + m.chosenProfile.Name() + "?")) sb.WriteString("\n") if inst, ok := m.chosenProfile.(profiles.Installer); ok { - sb.WriteString(" This will run: " + inst.InstallHint() + "\n") + 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")) From 7ddbdc462d513735a60bc19d2367b94f0f879fae Mon Sep 17 00:00:00 2001 From: Luke Kosewski Date: Sun, 19 Apr 2026 22:10:27 -0700 Subject: [PATCH 2/2] Fix gofmt errors. --- internal/profiles/claude_desktop.go | 4 ++-- internal/tui/tui.go | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/profiles/claude_desktop.go b/internal/profiles/claude_desktop.go index 95ac94f..e84e714 100644 --- a/internal/profiles/claude_desktop.go +++ b/internal/profiles/claude_desktop.go @@ -11,8 +11,8 @@ import ( // On launch, it re-checks the configuration and starts the desktop app. type ClaudeDesktopProfile struct{} -func (c *ClaudeDesktopProfile) Name() string { return "Claude Cowork" } -func (c *ClaudeDesktopProfile) BinaryName() string { return platformBinaryName() } +func (c *ClaudeDesktopProfile) Name() string { return "Claude Cowork" } +func (c *ClaudeDesktopProfile) BinaryName() string { return platformBinaryName() } func (c *ClaudeDesktopProfile) CommonPaths() []string { return platformCommonPaths() } func (c *ClaudeDesktopProfile) SupportedBackends() []Backend { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 10eddfc..c0dab91 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -172,6 +172,7 @@ 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) { switch msg := msg.(type) { case preflightResult: