Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions internal/profiles/claude_desktop.go
Original file line number Diff line number Diff line change
@@ -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, "/")
}
48 changes: 48 additions & 0 deletions internal/profiles/claude_desktop_darwin.go
Original file line number Diff line number Diff line change
@@ -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()
}
30 changes: 30 additions & 0 deletions internal/profiles/claude_desktop_other.go
Original file line number Diff line number Diff line change
@@ -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")
}
86 changes: 86 additions & 0 deletions internal/profiles/claude_desktop_windows.go
Original file line number Diff line number Diff line change
@@ -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()
}
44 changes: 36 additions & 8 deletions internal/profiles/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)

// BackendType identifies the upstream LLM provider.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions internal/profiles/profiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading