From d61902d42a1b7c4ec350ace4c7e280e58a9c7e2f Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Mon, 18 May 2026 13:01:39 +0800 Subject: [PATCH] feat(config): add ResetToDefaults and CLI `config reset` command Export MakeBackup for external use, add ResetToDefaults function that backs up current config, creates defaults, and preserves security credentials. Add `picoclaw config reset` CLI command with --force flag. --- cmd/picoclaw/internal/config/command.go | 57 +++++++++++++++++++++++++ cmd/picoclaw/main.go | 2 + pkg/config/config.go | 21 +++++++-- pkg/config/config_test.go | 26 +++++------ 4 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 cmd/picoclaw/internal/config/command.go diff --git a/cmd/picoclaw/internal/config/command.go b/cmd/picoclaw/internal/config/command.go new file mode 100644 index 00000000..3329d491 --- /dev/null +++ b/cmd/picoclaw/internal/config/command.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +func NewConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage configuration", + } + + cmd.AddCommand(newResetCommand()) + return cmd +} + +func newResetCommand() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "reset", + Short: "Reset configuration to factory defaults", + Args: cobra.NoArgs, + Example: ` picoclaw config reset + picoclaw config reset --force`, + RunE: func(_ *cobra.Command, _ []string) error { + if !force { + fmt.Print("Reset config to factory defaults? API keys will be preserved. (y/n): ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) != "y" { + fmt.Println("Aborted.") + return nil + } + } + + configPath := internal.GetConfigPath() + if err := config.ResetToDefaults(configPath); err != nil { + return fmt.Errorf("reset failed: %w", err) + } + fmt.Println("Configuration has been reset to factory defaults.") + fmt.Println("A backup of the previous config was created in the same directory.") + return nil + }, + } + + cmd.Flags().BoolVarP(&force, "force", "f", false, + "Skip confirmation prompt") + + return cmd +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index abcf03a3..11d0a8bb 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -16,6 +16,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" + configcmd "github.com/sipeed/picoclaw/cmd/picoclaw/internal/config" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" @@ -82,6 +83,7 @@ picoclaw --no-color status`, }) cmd.AddCommand( + configcmd.NewConfigCommand(), onboard.NewOnboardCommand(), agent.NewAgentCommand(), auth.NewAuthCommand(), diff --git a/pkg/config/config.go b/pkg/config/config.go index 8ed3da13..61d7cba5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1239,7 +1239,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } - err = makeBackup(path) + err = MakeBackup(path) if err != nil { return nil, err } @@ -1293,7 +1293,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } - err = makeBackup(path) + err = MakeBackup(path) if err != nil { return nil, err } @@ -1345,7 +1345,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } - err = makeBackup(path) + err = MakeBackup(path) if err != nil { return nil, err } @@ -1496,7 +1496,7 @@ func applySkillsRegistryEnvCompat(cfg *Config) { cfg.Tools.Skills.Registries.Set("github", githubCfg) } -func makeBackup(path string) error { +func MakeBackup(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { return nil } @@ -1624,6 +1624,19 @@ func (c *Config) SecurityCopyFrom(path string) error { return loadSecurityConfig(c, securityPath(path)) } +// ResetToDefaults backs up the current config, creates a default config, +// preserves security credentials from the existing config, and saves it. +func ResetToDefaults(configPath string) error { + if err := MakeBackup(configPath); err != nil { + return fmt.Errorf("backup before reset: %w", err) + } + cfg := DefaultConfig() + if err := cfg.SecurityCopyFrom(configPath); err != nil { + logger.WarnF("could not preserve security config", map[string]any{"error": err}) + } + return SaveConfig(configPath, cfg) +} + func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { var expanded []*ModelConfig diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 96417612..c808293f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2602,7 +2602,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { } // --------------------------------------------------------------------------- -// makeBackup tests +// MakeBackup tests // --------------------------------------------------------------------------- // TestMakeBackup_WithDateSuffix verifies backup files include a date suffix. @@ -2613,8 +2613,8 @@ func TestMakeBackup_WithDateSuffix(t *testing.T) { t.Fatalf("WriteFile: %v", err) } - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, err := os.ReadDir(dir) @@ -2653,8 +2653,8 @@ func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) { os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) os.WriteFile(secPath, []byte(`model_list:\n test:0:\n api_keys:\n - "sk-test"\n`), 0o600) - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, err := os.ReadDir(dir) @@ -2680,14 +2680,14 @@ func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) { } } -// TestMakeBackup_NonexistentFileSkipsBackup verifies that makeBackup returns nil +// TestMakeBackup_NonexistentFileSkipsBackup verifies that MakeBackup returns nil // when the config file does not exist (no error, no panic). func TestMakeBackup_NonexistentFileSkipsBackup(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "nonexistent.json") - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup on nonexistent file should return nil, got: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup on nonexistent file should return nil, got: %v", err) } } @@ -2698,8 +2698,8 @@ func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) { configPath := filepath.Join(dir, "config.json") os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, _ := os.ReadDir(dir) @@ -2722,7 +2722,7 @@ func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) { } // TestMakeBackup_SameDateSuffix verifies that config and security backups -// share the same date suffix (they are created in the same makeBackup call). +// share the same date suffix (they are created in the same MakeBackup call). func TestMakeBackup_SameDateSuffix(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -2731,8 +2731,8 @@ func TestMakeBackup_SameDateSuffix(t *testing.T) { os.WriteFile(configPath, []byte(`{"version":2}`), 0o600) os.WriteFile(secPath, []byte(`key: value`), 0o600) - if err := makeBackup(configPath); err != nil { - t.Fatalf("makeBackup: %v", err) + if err := MakeBackup(configPath); err != nil { + t.Fatalf("MakeBackup: %v", err) } entries, _ := os.ReadDir(dir)