Skip to content

Commit 2905d68

Browse files
lkosewskclaude
andauthored
profiles,tui: Add Claude Cowork/Claude Code GUI profile (#15)
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) <noreply@anthropic.com>
1 parent 70221f4 commit 2905d68

7 files changed

Lines changed: 361 additions & 9 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package profiles
2+
3+
import (
4+
"os/exec"
5+
"strings"
6+
)
7+
8+
// ClaudeDesktopProfile implements Profile for the Claude Cowork application
9+
// (Claude Code GUI). During install, it writes platform-specific gateway
10+
// configuration (macOS plist / Windows registry) and downloads the installer.
11+
// On launch, it re-checks the configuration and starts the desktop app.
12+
type ClaudeDesktopProfile struct{}
13+
14+
func (c *ClaudeDesktopProfile) Name() string { return "Claude Cowork" }
15+
func (c *ClaudeDesktopProfile) BinaryName() string { return platformBinaryName() }
16+
func (c *ClaudeDesktopProfile) CommonPaths() []string { return platformCommonPaths() }
17+
18+
func (c *ClaudeDesktopProfile) SupportedBackends() []Backend {
19+
return []Backend{
20+
{Type: BackendAnthropic, DisplayName: "Anthropic API"},
21+
}
22+
}
23+
24+
func (c *ClaudeDesktopProfile) RequiredCompat(b Backend) []string {
25+
switch b.Type {
26+
case BackendAnthropic:
27+
return []string{"anthropic_messages"}
28+
default:
29+
return nil
30+
}
31+
}
32+
33+
func (c *ClaudeDesktopProfile) InstallHint() string { return platformInstallHint() }
34+
35+
// RunInstall writes the gateway configuration and returns a command that
36+
// downloads and runs the installer. The TUI executes this with terminal
37+
// takeover so the user sees download progress.
38+
func (c *ClaudeDesktopProfile) RunInstall(apertureHost string) (*exec.Cmd, error) {
39+
if err := platformConfigure(GatewayURL(apertureHost)); err != nil {
40+
return nil, err
41+
}
42+
return platformInstallCmd(), nil
43+
}
44+
45+
// Launch checks whether the gateway configuration matches the current aperture
46+
// host, updates it if needed, and starts the desktop app.
47+
func (c *ClaudeDesktopProfile) Launch(apertureHost string) error {
48+
wantURL := GatewayURL(apertureHost)
49+
if currentURL := platformReadGatewayURL(); currentURL != wantURL {
50+
if err := platformConfigure(wantURL); err != nil {
51+
return err
52+
}
53+
}
54+
return platformLaunch()
55+
}
56+
57+
// Env is not used for desktop app profiles but satisfies the Profile interface.
58+
func (c *ClaudeDesktopProfile) Env(_ string, _ Backend) (map[string]string, error) {
59+
return nil, nil
60+
}
61+
62+
// GatewayURL normalizes the aperture host for Claude Cowork's gateway config.
63+
// Claude Cowork requires HTTPS and no trailing slash.
64+
func GatewayURL(apertureHost string) string {
65+
u := strings.Replace(apertureHost, "http://", "https://", 1)
66+
if !strings.HasPrefix(u, "https://") {
67+
u = "https://" + u
68+
}
69+
return strings.TrimRight(u, "/")
70+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package profiles
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"strings"
7+
)
8+
9+
func platformBinaryName() string { return "Claude" }
10+
11+
func platformCommonPaths() []string {
12+
return []string{"/Applications/Claude.app/Contents/MacOS/Claude"}
13+
}
14+
15+
func platformInstallHint() string {
16+
return "Opens the Claude download page in your browser.\nInstall the app, then come back here to launch it."
17+
}
18+
19+
func platformConfigure(baseURL string) error {
20+
domain := "com.anthropic.claude"
21+
entries := [][2]string{
22+
{"inferenceProvider", "gateway"},
23+
{"inferenceGatewayApiKey", "-"},
24+
{"inferenceGatewayBaseUrl", baseURL},
25+
}
26+
for _, e := range entries {
27+
if err := exec.Command("defaults", "write", domain, e[0], "-string", e[1]).Run(); err != nil {
28+
return fmt.Errorf("defaults write %s: %w", e[0], err)
29+
}
30+
}
31+
return nil
32+
}
33+
34+
func platformReadGatewayURL() string {
35+
out, err := exec.Command("defaults", "read", "com.anthropic.claude", "inferenceGatewayBaseUrl").Output()
36+
if err != nil {
37+
return ""
38+
}
39+
return strings.TrimSpace(string(out))
40+
}
41+
42+
func platformInstallCmd() *exec.Cmd {
43+
return exec.Command("open", "https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect?utm_source=aperture_cli")
44+
}
45+
46+
func platformLaunch() error {
47+
return exec.Command("open", "-a", "Claude").Run()
48+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//go:build !darwin && !windows
2+
3+
package profiles
4+
5+
import (
6+
"fmt"
7+
"os/exec"
8+
)
9+
10+
// On unsupported platforms, return a binary name that won't be found so the
11+
// profile doesn't appear as installed in the TUI.
12+
func platformBinaryName() string { return "claude-desktop-not-available" }
13+
14+
func platformCommonPaths() []string { return nil }
15+
16+
func platformInstallHint() string { return "" }
17+
18+
func platformConfigure(_ string) error {
19+
return fmt.Errorf("Claude Cowork configuration is only supported on macOS and Windows")
20+
}
21+
22+
func platformReadGatewayURL() string { return "" }
23+
24+
func platformInstallCmd() *exec.Cmd {
25+
return exec.Command("echo", "Claude Cowork is only supported on macOS and Windows")
26+
}
27+
28+
func platformLaunch() error {
29+
return fmt.Errorf("Claude Cowork is only supported on macOS and Windows")
30+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package profiles
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"runtime"
9+
"strings"
10+
)
11+
12+
func platformBinaryName() string { return "Claude.exe" }
13+
14+
func platformCommonPaths() []string {
15+
// MSIX install: query the package install location via PowerShell.
16+
out, err := exec.Command("powershell", "-Command",
17+
`(Get-AppxPackage -Name "Claude" | Select-Object -First 1).InstallLocation`).Output()
18+
if err == nil {
19+
loc := strings.TrimSpace(string(out))
20+
if loc != "" {
21+
p := filepath.Join(loc, "app", "Claude.exe")
22+
if _, err := os.Stat(p); err == nil {
23+
return []string{p}
24+
}
25+
}
26+
}
27+
28+
// Squirrel install fallback.
29+
localAppData := os.Getenv("LOCALAPPDATA")
30+
if localAppData == "" {
31+
return nil
32+
}
33+
return []string{
34+
filepath.Join(localAppData, "Programs", "claude-desktop", "Claude.exe"),
35+
}
36+
}
37+
38+
func platformInstallHint() string {
39+
return "Opens the Claude download page in your browser.\nInstall the app, then come back here to launch it."
40+
}
41+
42+
func platformConfigure(baseURL string) error {
43+
regPath := `HKCU\SOFTWARE\Policies\Claude`
44+
entries := [][2]string{
45+
{"inferenceProvider", "gateway"},
46+
{"inferenceGatewayApiKey", "-"},
47+
{"inferenceGatewayBaseUrl", baseURL},
48+
}
49+
for _, e := range entries {
50+
cmd := exec.Command("reg", "add", regPath, "/v", e[0], "/t", "REG_SZ", "/d", e[1], "/f")
51+
if err := cmd.Run(); err != nil {
52+
return fmt.Errorf("reg add %s: %w", e[0], err)
53+
}
54+
}
55+
return nil
56+
}
57+
58+
func platformReadGatewayURL() string {
59+
out, err := exec.Command("reg", "query", `HKCU\SOFTWARE\Policies\Claude`, "/v", "inferenceGatewayBaseUrl").Output()
60+
if err != nil {
61+
return ""
62+
}
63+
// reg query output: " inferenceGatewayBaseUrl REG_SZ https://..."
64+
for _, line := range strings.Split(string(out), "\n") {
65+
line = strings.TrimSpace(line)
66+
if strings.Contains(line, "inferenceGatewayBaseUrl") {
67+
parts := strings.Fields(line)
68+
if len(parts) >= 3 {
69+
return parts[len(parts)-1]
70+
}
71+
}
72+
}
73+
return ""
74+
}
75+
76+
func platformInstallCmd() *exec.Cmd {
77+
url := "https://claude.ai/api/desktop/win32/x64/setup/latest/redirect?utm_source=aperture_cli"
78+
if runtime.GOARCH == "arm64" {
79+
url = "https://claude.ai/api/desktop/win32/arm64/setup/latest/redirect?utm_source=aperture_cli"
80+
}
81+
return exec.Command("cmd", "/c", "start", "", url)
82+
}
83+
84+
func platformLaunch() error {
85+
return exec.Command("cmd", "/c", "start", "", "claude://").Run()
86+
}

internal/profiles/profiles.go

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"os"
66
"os/exec"
77
"path/filepath"
8+
"runtime"
9+
"strings"
810
)
911

1012
// BackendType identifies the upstream LLM provider.
@@ -80,14 +82,16 @@ type Manager struct {
8082

8183
// NewManager returns a Manager with all built-in profiles registered.
8284
func NewManager() *Manager {
83-
return &Manager{
84-
profiles: []Profile{
85-
&ClaudeCodeProfile{},
86-
&GeminiCLIProfile{},
87-
&OpenCodeProfile{},
88-
&CodexProfile{},
89-
},
85+
p := []Profile{
86+
&ClaudeCodeProfile{},
87+
&GeminiCLIProfile{},
88+
&OpenCodeProfile{},
89+
&CodexProfile{},
9090
}
91+
if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
92+
p = append(p, &ClaudeDesktopProfile{})
93+
}
94+
return &Manager{profiles: p}
9195
}
9296

9397
// PathHinter is implemented by profiles that know common filesystem
@@ -168,8 +172,16 @@ func isExecutable(path string) bool {
168172
if err != nil {
169173
return false
170174
}
175+
if info.IsDir() {
176+
return false
177+
}
178+
// On Windows, permission bits are not meaningful; check the file extension.
179+
if runtime.GOOS == "windows" {
180+
ext := strings.ToLower(filepath.Ext(path))
181+
return ext == ".exe" || ext == ".cmd" || ext == ".bat" || ext == ".com"
182+
}
171183
// On Unix, check that at least one execute bit is set.
172-
return !info.IsDir() && info.Mode()&0o111 != 0
184+
return info.Mode()&0o111 != 0
173185
}
174186

175187
// Installer is implemented by profiles that can provide installation
@@ -192,6 +204,22 @@ type Uninstaller interface {
192204
Uninstall() func() error
193205
}
194206

207+
// Launcher is implemented by profiles that launch a desktop application
208+
// rather than a CLI tool. Launch may update configuration before starting
209+
// the app, and returns immediately after launch.
210+
type Launcher interface {
211+
Launch(apertureHost string) error
212+
}
213+
214+
// HostAwareInstaller is implemented by profiles whose installation requires
215+
// the aperture host URL (e.g. to write platform config alongside the binary
216+
// install). RunInstall writes any platform config and returns an exec.Cmd
217+
// that downloads and runs the installer. The TUI executes the command with
218+
// terminal takeover so the user sees download progress.
219+
type HostAwareInstaller interface {
220+
RunInstall(apertureHost string) (*exec.Cmd, error)
221+
}
222+
195223
// AllProfiles returns all registered profiles regardless of installation status.
196224
func (m *Manager) AllProfiles() []Profile {
197225
return m.profiles

internal/profiles/profiles_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,51 @@ func TestLauncher_ClaudeCode_Check_EmptyEnv(t *testing.T) {
703703
}
704704
}
705705

706+
func TestLauncher_ClaudeDesktop_GatewayURL(t *testing.T) {
707+
tests := []struct {
708+
input string
709+
want string
710+
}{
711+
{"http://ai", "https://ai"},
712+
{"https://my-aperture.ts.net", "https://my-aperture.ts.net"},
713+
{"http://ai/", "https://ai"},
714+
{"https://aperture.example.com/", "https://aperture.example.com"},
715+
{"ai.example.com", "https://ai.example.com"},
716+
{"http://ai:8080/", "https://ai:8080"},
717+
}
718+
for _, tt := range tests {
719+
got := profiles.GatewayURL(tt.input)
720+
if got != tt.want {
721+
t.Errorf("GatewayURL(%q) = %q, want %q", tt.input, got, tt.want)
722+
}
723+
}
724+
}
725+
726+
func TestLauncher_ClaudeDesktop_ImplementsLauncher(t *testing.T) {
727+
p := &profiles.ClaudeDesktopProfile{}
728+
if _, ok := profiles.Profile(p).(profiles.Launcher); !ok {
729+
t.Fatal("ClaudeDesktopProfile does not implement Launcher")
730+
}
731+
}
732+
733+
func TestLauncher_ClaudeDesktop_ImplementsHostAwareInstaller(t *testing.T) {
734+
p := &profiles.ClaudeDesktopProfile{}
735+
if _, ok := profiles.Profile(p).(profiles.HostAwareInstaller); !ok {
736+
t.Fatal("ClaudeDesktopProfile does not implement HostAwareInstaller")
737+
}
738+
}
739+
740+
func TestLauncher_ClaudeDesktop_SupportedBackends(t *testing.T) {
741+
p := &profiles.ClaudeDesktopProfile{}
742+
backends := p.SupportedBackends()
743+
if len(backends) != 1 {
744+
t.Fatalf("expected 1 backend, got %d", len(backends))
745+
}
746+
if backends[0].Type != profiles.BackendAnthropic {
747+
t.Errorf("backend type = %q, want %q", backends[0].Type, profiles.BackendAnthropic)
748+
}
749+
}
750+
706751
func TestLauncher_AllProfiles_ImplementPathHinter(t *testing.T) {
707752
mgr := profiles.NewManager()
708753
for _, p := range mgr.AllProfiles() {

0 commit comments

Comments
 (0)