Skip to content

Commit b4854ad

Browse files
committed
feat(core): snapshot CLI configs before switch and restore on reset.
Persist pre-clovapi config bytes in profiles.json so reset can fully restore originals, with legacy field deletion as fallback when no snapshot exists.
1 parent 4935f1e commit b4854ad

8 files changed

Lines changed: 476 additions & 37 deletions

File tree

core/cmd/switch_cmd.go

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/clovapi/switcher/internal/apistyle"
1313
"github.com/clovapi/switcher/internal/apply"
1414
"github.com/clovapi/switcher/internal/cliswitch"
15+
"github.com/clovapi/switcher/internal/desktop"
1516
"github.com/clovapi/switcher/internal/profile"
1617
"github.com/clovapi/switcher/internal/syslog"
1718
)
@@ -27,14 +28,11 @@ func runSwitch(sc *bufio.Scanner, s *profile.Store, kind agentkind.Kind, resetFl
2728
if strings.TrimSpace(providerFlag) != "" || strings.TrimSpace(vendorFlag) != "" || strings.TrimSpace(modelFlag) != "" {
2829
return fmt.Errorf("cannot use --reset with --provider, --vendor or --model")
2930
}
30-
if err := apply.ResetDefault(kind); err != nil {
31+
if err := desktop.ResetCLIToDefault(kind); err != nil {
3132
return err
3233
}
3334
syslog.LogCLIReset(kind)
34-
if err := clearActiveBinding(kind); err != nil {
35-
return err
36-
}
37-
fmt.Printf("Reset %s to default (cleared clovapi relay bindings).\n", kind)
35+
fmt.Printf("Reset %s to default (restored backup when available; otherwise cleared clovapi relay bindings).\n", kind)
3836
return nil
3937
}
4038

@@ -57,13 +55,10 @@ func runSwitch(sc *bufio.Scanner, s *profile.Store, kind agentkind.Kind, resetFl
5755
return err
5856
} else if ok {
5957
if picked.reset {
60-
if err := apply.ResetDefault(kind); err != nil {
61-
return err
62-
}
63-
if err := clearActiveBinding(kind); err != nil {
58+
if err := desktop.ResetCLIToDefault(kind); err != nil {
6459
return err
6560
}
66-
fmt.Printf("Reset %s to default (cleared clovapi relay bindings).\n", kind)
61+
fmt.Printf("Reset %s to default (restored backup when available; otherwise cleared clovapi relay bindings).\n", kind)
6762
return nil
6863
}
6964
return applyProviderModelSwitch(kind, picked.selection.ProviderID, picked.selection.ModelID)
@@ -76,40 +71,23 @@ func runSwitch(sc *bufio.Scanner, s *profile.Store, kind agentkind.Kind, resetFl
7671
}
7772

7873
if !switchNeedsInteractive(sc, s, kind, bindingFlag, providerFlag, vendorFlag, modelFlag, directBaseURL, positional) {
79-
return fmt.Errorf("no model selected for %s pass Vendor/model, or --vendor with --model, or --binding", kind)
74+
return fmt.Errorf("no model selected for %s - pass Vendor/model, or --vendor with --model, or --binding", kind)
8075
}
8176

8277
picked, err := promptSwitchForCLI(sc, kind, s)
8378
if err != nil {
8479
return err
8580
}
8681
if picked.reset {
87-
if err := apply.ResetDefault(kind); err != nil {
88-
return err
89-
}
90-
if err := clearActiveBinding(kind); err != nil {
82+
if err := desktop.ResetCLIToDefault(kind); err != nil {
9183
return err
9284
}
93-
fmt.Printf("Reset %s to default (cleared clovapi relay bindings).\n", kind)
85+
fmt.Printf("Reset %s to default (restored backup when available; otherwise cleared clovapi relay bindings).\n", kind)
9486
return nil
9587
}
9688
return applyProviderModelSwitch(kind, picked.selection.ProviderID, picked.selection.ModelID)
9789
}
9890

99-
func clearActiveBinding(kind agentkind.Kind) error {
100-
_, err := profile.WithLockedStore(func(s *profile.Store) (bool, error) {
101-
if s.Active == nil {
102-
return false, nil
103-
}
104-
if _, ok := s.Active[string(kind)]; !ok {
105-
return false, nil
106-
}
107-
s.ClearActive(string(kind))
108-
return true, nil
109-
})
110-
return err
111-
}
112-
11391
type switchPick struct {
11492
selection profile.ActiveSelection
11593
reset bool
@@ -118,7 +96,7 @@ type switchPick struct {
11896
func promptSwitchForCLI(sc *bufio.Scanner, kind agentkind.Kind, s *profile.Store) (switchPick, error) {
11997
vendors := cliswitch.VendorsForCLI(s, kind)
12098
if len(vendors) == 0 {
121-
return switchPick{}, fmt.Errorf("no compatible vendors for %s configure providers in the desktop app or profiles.json", kind)
99+
return switchPick{}, fmt.Errorf("no compatible vendors for %s - configure providers in the desktop app or profiles.json", kind)
122100
}
123101

124102
fmt.Println()
@@ -154,7 +132,7 @@ func promptSwitchForCLI(sc *bufio.Scanner, kind agentkind.Kind, s *profile.Store
154132
return switchPick{reset: true}, nil
155133
}
156134
if n < 1 || n > len(vendorPicks) {
157-
return switchPick{}, fmt.Errorf("choose 0%d", len(vendorPicks))
135+
return switchPick{}, fmt.Errorf("choose 0-%d", len(vendorPicks))
158136
}
159137
return promptModelForVendor(sc, kind, s, vendorPicks[n-1], activeProvider, activeModel, hasActive)
160138
}
@@ -218,7 +196,7 @@ func promptModelForVendor(sc *bufio.Scanner, kind agentkind.Kind, s *profile.Sto
218196
}
219197
if n, err := strconv.Atoi(line); err == nil {
220198
if n < 1 || n > len(modelPicks) {
221-
return switchPick{}, fmt.Errorf("choose 1%d", len(modelPicks))
199+
return switchPick{}, fmt.Errorf("choose 1-%d", len(modelPicks))
222200
}
223201
modelID := strings.TrimSpace(modelPicks[n-1].ID)
224202
selection, err := cliswitch.ResolveSelection(s, kind, vendorName, modelID)
@@ -253,7 +231,7 @@ func modelStyleShow(m profile.Model) string {
253231
if strings.TrimSpace(string(m.APIStyle)) != "" {
254232
return string(m.APIStyle)
255233
}
256-
return ""
234+
return "-"
257235
}
258236

259237
func resolveSwitchSelectionOrError(s *profile.Store, kind agentkind.Kind, providerFlag, vendorFlag, modelFlag, bindingFlag, positional string) (profile.ActiveSelection, error) {
@@ -349,6 +327,9 @@ func applyDirectToCLI(kind agentkind.Kind, baseURL, apiKey, model, styleStr stri
349327
if !apply.KindSupportsStyle(kind, p.APIStyle) {
350328
return fmt.Errorf("cli %q does not support api_style %q (supported here: %s)", kind, p.APIStyle, styleChoices(kind))
351329
}
330+
if err := desktop.EnsureCLIBackup(kind); err != nil {
331+
return err
332+
}
352333
if err := apply.Apply(p); err != nil {
353334
return err
354335
}

core/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "strings"
44

55
// Set at link time via -ldflags (see .goreleaser.yaml).
66
var (
7-
Version = "dev0.1.71"
7+
Version = "dev0.1.73"
88
Commit = "none"
99
Date = "unknown"
1010
)

core/internal/desktop/backup.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package desktop
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/clovapi/switcher/internal/agentkind"
10+
"github.com/clovapi/switcher/internal/apply"
11+
"github.com/clovapi/switcher/internal/profile"
12+
)
13+
14+
func cliConfigPath(kind agentkind.Kind) (string, error) {
15+
switch kind {
16+
case agentkind.ClaudeCode:
17+
return apply.ClaudeSettingsPath()
18+
case agentkind.Codex:
19+
return apply.CodexConfigPath()
20+
case agentkind.OpenCode:
21+
return apply.OpenCodeConfigPath()
22+
case agentkind.OpenClaw:
23+
return apply.OpenClawConfigPath()
24+
case agentkind.Hermes:
25+
return apply.HermesConfigPath()
26+
case agentkind.KimiCode:
27+
return apply.KimiConfigPath()
28+
default:
29+
return "", fmt.Errorf("unsupported cli %q", kind)
30+
}
31+
}
32+
33+
func writeBackupFileAtomic(path string, data []byte, perm os.FileMode) error {
34+
dir := filepath.Dir(path)
35+
if err := os.MkdirAll(dir, 0o700); err != nil {
36+
return err
37+
}
38+
tmp := path + ".tmp"
39+
if err := os.WriteFile(tmp, data, perm); err != nil {
40+
return err
41+
}
42+
if err := os.Rename(tmp, path); err != nil {
43+
_ = os.Remove(tmp)
44+
return err
45+
}
46+
return nil
47+
}
48+
49+
// EnsureCLIBackup captures the original config file before clovapi mutates it.
50+
// Once a backup exists for the CLI, later switches keep reusing it until reset.
51+
func EnsureCLIBackup(kind agentkind.Kind) error {
52+
_, err := profile.WithLockedStore(func(s *profile.Store) (bool, error) {
53+
if _, ok := s.BackupForCLI(string(kind)); ok {
54+
return false, nil
55+
}
56+
path, err := cliConfigPath(kind)
57+
if err != nil {
58+
return false, err
59+
}
60+
backup := profile.ConfigBackup{Path: path}
61+
data, err := os.ReadFile(path)
62+
if err != nil {
63+
if !os.IsNotExist(err) {
64+
return false, err
65+
}
66+
} else {
67+
backup.Existed = true
68+
backup.Content = append([]byte(nil), data...)
69+
}
70+
s.SetBackup(string(kind), backup)
71+
if s.Version < profile.StoreVersion {
72+
s.Version = profile.StoreVersion
73+
}
74+
return true, nil
75+
})
76+
return err
77+
}
78+
79+
func restoreBackupFile(kind agentkind.Kind, backup profile.ConfigBackup) error {
80+
path, err := cliConfigPath(kind)
81+
if err != nil {
82+
return err
83+
}
84+
if backup.Existed {
85+
return writeBackupFileAtomic(path, backup.Content, 0o600)
86+
}
87+
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
88+
return err
89+
}
90+
stale := strings.TrimSpace(backup.Path)
91+
if stale != "" && stale != path {
92+
if err := os.Remove(stale); err != nil && !os.IsNotExist(err) {
93+
return err
94+
}
95+
}
96+
return nil
97+
}
98+
99+
// ResetCLIToDefault restores the exact pre-clovapi config snapshot when one is
100+
// available. Older stores without a snapshot fall back to the legacy
101+
// best-effort field deletion logic.
102+
func ResetCLIToDefault(kind agentkind.Kind) error {
103+
restored := false
104+
_, err := profile.WithLockedStore(func(s *profile.Store) (bool, error) {
105+
backup, ok := s.BackupForCLI(string(kind))
106+
if !ok {
107+
return false, nil
108+
}
109+
if err := restoreBackupFile(kind, backup); err != nil {
110+
return false, err
111+
}
112+
s.ClearBackup(string(kind))
113+
s.ClearActive(string(kind))
114+
restored = true
115+
return true, nil
116+
})
117+
if err != nil {
118+
return err
119+
}
120+
if restored {
121+
return nil
122+
}
123+
if err := apply.ResetDefault(kind); err != nil {
124+
return err
125+
}
126+
_, err = profile.WithLockedStore(func(s *profile.Store) (bool, error) {
127+
changed := false
128+
if _, ok := s.BackupForCLI(string(kind)); ok {
129+
s.ClearBackup(string(kind))
130+
changed = true
131+
}
132+
if _, ok := s.Active[string(kind)]; ok {
133+
s.ClearActive(string(kind))
134+
changed = true
135+
}
136+
return changed, nil
137+
})
138+
return err
139+
}

0 commit comments

Comments
 (0)