Skip to content

Commit ff1308b

Browse files
joohwcursoragent
andcommitted
feat(core,desktop): add Claude Desktop apply target and reset settings
Route Claude Desktop through the local proxy via 3P gateway config, detect installation from app paths, and skip CLI config backup on apply. Desktop UI adds a dedicated Reset settings action and lists Claude Desktop in Agents. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 098e656 commit ff1308b

18 files changed

Lines changed: 543 additions & 48 deletions

File tree

core/internal/agentkind/clikind.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,21 @@ import (
99
type Kind string
1010

1111
const (
12-
ClaudeCode Kind = "claude-code"
13-
Codex Kind = "codex"
14-
OpenCode Kind = "opencode"
15-
OpenClaw Kind = "openclaw"
16-
Hermes Kind = "hermes"
17-
KimiCode Kind = "kimi-code"
12+
ClaudeCode Kind = "claude-code"
13+
ClaudeDesktop Kind = "claudedesktop"
14+
Codex Kind = "codex"
15+
OpenCode Kind = "opencode"
16+
OpenClaw Kind = "openclaw"
17+
Hermes Kind = "hermes"
18+
KimiCode Kind = "kimi-code"
1819
)
1920

2021
func Parse(s string) (Kind, error) {
2122
switch strings.ToLower(strings.TrimSpace(s)) {
2223
case string(ClaudeCode):
2324
return ClaudeCode, nil
25+
case string(ClaudeDesktop), "claude-desktop", "claude desktop":
26+
return ClaudeDesktop, nil
2427
case string(Codex):
2528
return Codex, nil
2629
case string(OpenCode):
@@ -32,7 +35,7 @@ func Parse(s string) (Kind, error) {
3235
case string(KimiCode), "kimi":
3336
return KimiCode, nil
3437
default:
35-
return "", fmt.Errorf("unknown cli %q (want claude-code|codex|opencode|openclaw|hermes|kimi-code)", s)
38+
return "", fmt.Errorf("unknown cli %q (want claude-code|claudedesktop|codex|opencode|openclaw|hermes|kimi-code)", s)
3639
}
3740
}
3841

core/internal/apply/adapter.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type ProfileTarget interface {
1515
SupportedStyles() []apistyle.Style
1616
Description() string
1717
Apply(p profile.Profile) error
18-
// ResetDefault removes clovapi-injected settings from that tools config (best-effort; no-op if file missing).
18+
// ResetDefault removes clovapi-injected settings from that tool's config (best-effort; no-op if file missing).
1919
ResetDefault() error
2020
// Installed reports whether this CLI appears present on the local machine (e.g. binary on PATH).
2121
Installed() bool
@@ -45,6 +45,7 @@ func TargetFor(k agentkind.Kind) (ProfileTarget, bool) {
4545
func RegisteredKinds() []agentkind.Kind {
4646
return []agentkind.Kind{
4747
agentkind.ClaudeCode,
48+
agentkind.ClaudeDesktop,
4849
agentkind.Codex,
4950
agentkind.OpenCode,
5051
agentkind.OpenClaw,
@@ -109,7 +110,7 @@ func Apply(p profile.Profile) error {
109110
return nil
110111
}
111112

112-
// ResetDefault runs the registered targets ResetDefault (clears relay bindings written by Apply).
113+
// ResetDefault runs the registered target's ResetDefault (clears relay bindings written by Apply).
113114
func ResetDefault(k agentkind.Kind) error {
114115
t, ok := TargetFor(k)
115116
if !ok || t == nil {

core/internal/apply/register_default.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package apply
22

33
func init() {
44
Register(claudeCodeTarget{})
5+
Register(claudeDesktopTarget{})
56
Register(codexTarget{})
67
Register(openCodeTarget{})
78
Register(openClawTarget{})
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
package apply
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"runtime"
9+
"strings"
10+
11+
"github.com/clovapi/switcher/internal/agentkind"
12+
"github.com/clovapi/switcher/internal/apistyle"
13+
"github.com/clovapi/switcher/internal/profile"
14+
)
15+
16+
const (
17+
claudeDesktopProfileID = "00000000-0000-4000-8000-000000274830"
18+
claudeDesktopProfileName = "clovapi"
19+
envClaudeDesktopDir = "CLOVAPI_SWITCHER_CLAUDE_DESKTOP_DIR"
20+
)
21+
22+
type claudeDesktopTarget struct{}
23+
24+
type claudeDesktopPaths struct {
25+
normalConfigPath string
26+
threepConfigPath string
27+
profilePath string
28+
metaPath string
29+
}
30+
31+
type claudeDesktopSnapshot struct {
32+
path string
33+
exists bool
34+
content []byte
35+
}
36+
37+
func (claudeDesktopTarget) Kind() agentkind.Kind { return agentkind.ClaudeDesktop }
38+
39+
func (claudeDesktopTarget) SupportedStyles() []apistyle.Style {
40+
return []apistyle.Style{apistyle.Claude}
41+
}
42+
43+
func (claudeDesktopTarget) Description() string {
44+
return "Claude Desktop 3P gateway config in Claude-3p/configLibrary (inferenceGatewayBaseUrl)"
45+
}
46+
47+
func (claudeDesktopTarget) Installed() bool {
48+
for _, path := range claudeDesktopAppPaths() {
49+
if regularFile(path) || directoryExists(path) {
50+
return true
51+
}
52+
}
53+
return false
54+
}
55+
56+
func (claudeDesktopTarget) Apply(p profile.Profile) error {
57+
if p.CLI != agentkind.ClaudeDesktop || p.APIStyle != apistyle.Claude {
58+
return errWrongAdapter("claudedesktop", "claude", p)
59+
}
60+
paths, err := ClaudeDesktopPaths()
61+
if err != nil {
62+
return err
63+
}
64+
return withClaudeDesktopRollback(paths, func() error {
65+
profileJSON := claudeDesktopGatewayProfile(ensureAnthropicWireBaseURL(p.BaseURL), p.APIKey, p.Model)
66+
if err := writeClaudeDesktopDeploymentMode(paths.normalConfigPath, "3p"); err != nil {
67+
return err
68+
}
69+
if err := writeClaudeDesktopDeploymentMode(paths.threepConfigPath, "3p"); err != nil {
70+
return err
71+
}
72+
if err := writeJSONAtomic(paths.profilePath, profileJSON, 0o600); err != nil {
73+
return err
74+
}
75+
return writeClaudeDesktopMeta(paths.metaPath, true)
76+
})
77+
}
78+
79+
func (claudeDesktopTarget) ResetDefault() error {
80+
paths, err := ClaudeDesktopPaths()
81+
if err != nil {
82+
return err
83+
}
84+
return withClaudeDesktopRollback(paths, func() error {
85+
if err := writeClaudeDesktopDeploymentMode(paths.normalConfigPath, "1p"); err != nil {
86+
return err
87+
}
88+
if err := writeClaudeDesktopDeploymentMode(paths.threepConfigPath, "1p"); err != nil {
89+
return err
90+
}
91+
if err := os.Remove(paths.profilePath); err != nil && !os.IsNotExist(err) {
92+
return err
93+
}
94+
return writeClaudeDesktopMeta(paths.metaPath, false)
95+
})
96+
}
97+
98+
func ClaudeDesktopPaths() (claudeDesktopPaths, error) {
99+
if root := strings.TrimSpace(os.Getenv(envClaudeDesktopDir)); root != "" {
100+
return claudeDesktopPathsFromDirs(filepath.Join(root, "Claude"), filepath.Join(root, "Claude-3p")), nil
101+
}
102+
switch runtime.GOOS {
103+
case "darwin":
104+
h, err := userHome()
105+
if err != nil {
106+
return claudeDesktopPaths{}, err
107+
}
108+
appSupport := filepath.Join(h, "Library", "Application Support")
109+
return claudeDesktopPathsFromDirs(filepath.Join(appSupport, "Claude"), filepath.Join(appSupport, "Claude-3p")), nil
110+
case "windows":
111+
local := strings.TrimSpace(os.Getenv("LOCALAPPDATA"))
112+
if local == "" {
113+
h, err := userHome()
114+
if err != nil {
115+
return claudeDesktopPaths{}, err
116+
}
117+
local = filepath.Join(h, "AppData", "Local")
118+
}
119+
return claudeDesktopPathsFromDirs(filepath.Join(local, "Claude"), filepath.Join(local, "Claude-3p")), nil
120+
default:
121+
return claudeDesktopPaths{}, fmt.Errorf("Claude Desktop 3P configuration is only supported on macOS and Windows")
122+
}
123+
}
124+
125+
func ClaudeDesktopProfilePath() (string, error) {
126+
paths, err := ClaudeDesktopPaths()
127+
if err != nil {
128+
return "", err
129+
}
130+
return paths.profilePath, nil
131+
}
132+
133+
func claudeDesktopAppPaths() []string {
134+
switch runtime.GOOS {
135+
case "darwin":
136+
h, _ := userHome()
137+
paths := []string{
138+
"/Applications/Claude.app",
139+
}
140+
if strings.TrimSpace(h) != "" {
141+
paths = append(paths, filepath.Join(h, "Applications", "Claude.app"))
142+
}
143+
return paths
144+
case "windows":
145+
var paths []string
146+
if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" {
147+
paths = append(paths,
148+
filepath.Join(local, "Programs", "Claude", "Claude.exe"),
149+
filepath.Join(local, "Claude", "Claude.exe"),
150+
)
151+
}
152+
for _, env := range []string{"ProgramFiles", "ProgramFiles(x86)"} {
153+
if root := strings.TrimSpace(os.Getenv(env)); root != "" {
154+
paths = append(paths, filepath.Join(root, "Claude", "Claude.exe"))
155+
}
156+
}
157+
return paths
158+
default:
159+
return nil
160+
}
161+
}
162+
163+
func directoryExists(path string) bool {
164+
info, err := os.Stat(path)
165+
return err == nil && info.IsDir()
166+
}
167+
168+
func claudeDesktopPathsFromDirs(normalDir, threepDir string) claudeDesktopPaths {
169+
library := filepath.Join(threepDir, "configLibrary")
170+
return claudeDesktopPaths{
171+
normalConfigPath: filepath.Join(normalDir, "claude_desktop_config.json"),
172+
threepConfigPath: filepath.Join(threepDir, "claude_desktop_config.json"),
173+
profilePath: filepath.Join(library, claudeDesktopProfileID+".json"),
174+
metaPath: filepath.Join(library, "_meta.json"),
175+
}
176+
}
177+
178+
func claudeDesktopGatewayProfile(baseURL, apiKey, model string) map[string]any {
179+
root := map[string]any{
180+
"coworkEgressAllowedHosts": []string{"*"},
181+
"disableDeploymentModeChooser": true,
182+
"inferenceGatewayApiKey": strings.TrimSpace(apiKey),
183+
"inferenceGatewayAuthScheme": "bearer",
184+
"inferenceGatewayBaseUrl": strings.TrimRight(strings.TrimSpace(baseURL), "/"),
185+
"inferenceProvider": "gateway",
186+
}
187+
if m := strings.TrimSpace(model); m != "" {
188+
root["inferenceModels"] = []any{map[string]any{"name": m}}
189+
}
190+
return root
191+
}
192+
193+
func writeClaudeDesktopDeploymentMode(path, mode string) error {
194+
root, err := readJSONObjectOrEmpty(path)
195+
if err != nil {
196+
return err
197+
}
198+
root["deploymentMode"] = mode
199+
return writeJSONAtomic(path, root, 0o600)
200+
}
201+
202+
func writeClaudeDesktopMeta(path string, applied bool) error {
203+
root, err := readJSONObjectOrEmpty(path)
204+
if err != nil {
205+
return err
206+
}
207+
entries := jsonArray(root["entries"])
208+
filtered := make([]any, 0, len(entries)+1)
209+
for _, entry := range entries {
210+
m, _ := entry.(map[string]any)
211+
if m != nil && m["id"] == claudeDesktopProfileID {
212+
continue
213+
}
214+
filtered = append(filtered, entry)
215+
}
216+
if applied {
217+
filtered = append(filtered, map[string]any{"id": claudeDesktopProfileID, "name": claudeDesktopProfileName})
218+
root["appliedId"] = claudeDesktopProfileID
219+
} else if root["appliedId"] == claudeDesktopProfileID {
220+
delete(root, "appliedId")
221+
}
222+
root["entries"] = filtered
223+
return writeJSONAtomic(path, root, 0o600)
224+
}
225+
226+
func readJSONObjectOrEmpty(path string) (map[string]any, error) {
227+
data, err := os.ReadFile(path)
228+
if err != nil {
229+
if os.IsNotExist(err) {
230+
return map[string]any{}, nil
231+
}
232+
return nil, err
233+
}
234+
if len(data) == 0 {
235+
return map[string]any{}, nil
236+
}
237+
var root map[string]any
238+
if err := json.Unmarshal(data, &root); err != nil {
239+
return nil, fmt.Errorf("parse existing %s: %w (refusing to overwrite; fix or remove the file)", path, err)
240+
}
241+
if root == nil {
242+
root = map[string]any{}
243+
}
244+
return root, nil
245+
}
246+
247+
func jsonArray(v any) []any {
248+
if xs, ok := v.([]any); ok {
249+
return xs
250+
}
251+
return nil
252+
}
253+
254+
func writeJSONAtomic(path string, v any, perm os.FileMode) error {
255+
out, err := json.MarshalIndent(v, "", " ")
256+
if err != nil {
257+
return err
258+
}
259+
return writeFileAtomic(path, out, perm)
260+
}
261+
262+
func withClaudeDesktopRollback(paths claudeDesktopPaths, op func() error) error {
263+
snapshots, err := snapshotClaudeDesktopFiles(paths)
264+
if err != nil {
265+
return err
266+
}
267+
if err := op(); err != nil {
268+
if rollbackErr := restoreClaudeDesktopSnapshots(snapshots); rollbackErr != nil {
269+
return fmt.Errorf("%w; rollback failed: %v", err, rollbackErr)
270+
}
271+
return err
272+
}
273+
return nil
274+
}
275+
276+
func snapshotClaudeDesktopFiles(paths claudeDesktopPaths) ([]claudeDesktopSnapshot, error) {
277+
files := []string{paths.normalConfigPath, paths.threepConfigPath, paths.profilePath, paths.metaPath}
278+
out := make([]claudeDesktopSnapshot, 0, len(files))
279+
for _, path := range files {
280+
data, err := os.ReadFile(path)
281+
if err != nil {
282+
if os.IsNotExist(err) {
283+
out = append(out, claudeDesktopSnapshot{path: path})
284+
continue
285+
}
286+
return nil, err
287+
}
288+
out = append(out, claudeDesktopSnapshot{path: path, exists: true, content: data})
289+
}
290+
return out, nil
291+
}
292+
293+
func restoreClaudeDesktopSnapshots(snapshots []claudeDesktopSnapshot) error {
294+
for _, snap := range snapshots {
295+
if snap.exists {
296+
if err := writeFileAtomic(snap.path, snap.content, 0o600); err != nil {
297+
return err
298+
}
299+
continue
300+
}
301+
if err := os.Remove(snap.path); err != nil && !os.IsNotExist(err) {
302+
return err
303+
}
304+
}
305+
return nil
306+
}

0 commit comments

Comments
 (0)