package migrate import ( "fmt" "io" "os" "path/filepath" "strings" "github.com/sipeed/picoclaw/pkg/config" ) type ActionType int const ( ActionCopy ActionType = iota ActionSkip ActionBackup ActionConvertConfig ActionCreateDir ActionMergeConfig ) type Options struct { DryRun bool ConfigOnly bool WorkspaceOnly bool Force bool Refresh bool OpenClawHome string PicoClawHome string } type Action struct { Type ActionType Source string Destination string Description string } type Result struct { FilesCopied int FilesSkipped int BackupsCreated int ConfigMigrated bool DirsCreated int Warnings []string Errors []error } func Run(opts Options) (*Result, error) { if opts.ConfigOnly && opts.WorkspaceOnly { return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive") } if opts.Refresh { opts.WorkspaceOnly = true } openclawHome, err := resolveOpenClawHome(opts.OpenClawHome) if err != nil { return nil, err } picoClawHome, err := resolvePicoClawHome(opts.PicoClawHome) if err != nil { return nil, err } if _, err := os.Stat(openclawHome); os.IsNotExist(err) { return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome) } actions, warnings, err := Plan(opts, openclawHome, picoClawHome) if err != nil { return nil, err } fmt.Println("Migrating from OpenClaw to PicoClaw") fmt.Printf(" Source: %s\n", openclawHome) fmt.Printf(" Destination: %s\n", picoClawHome) fmt.Println() if opts.DryRun { PrintPlan(actions, warnings) return &Result{Warnings: warnings}, nil } if !opts.Force { PrintPlan(actions, warnings) if !Confirm() { fmt.Println("Aborted.") return &Result{Warnings: warnings}, nil } fmt.Println() } result := Execute(actions, openclawHome, picoClawHome) result.Warnings = warnings return result, nil } func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string, error) { var actions []Action var warnings []string force := opts.Force || opts.Refresh if !opts.WorkspaceOnly { configPath, err := findOpenClawConfig(openclawHome) if err != nil { if opts.ConfigOnly { return nil, nil, err } warnings = append(warnings, fmt.Sprintf("Config migration skipped: %v", err)) } else { actions = append(actions, Action{ Type: ActionConvertConfig, Source: configPath, Destination: filepath.Join(picoClawHome, "config.json"), Description: "convert OpenClaw config to PicoClaw format", }) data, err := LoadOpenClawConfig(configPath) if err == nil { _, configWarnings, _ := ConvertConfig(data) warnings = append(warnings, configWarnings...) } } } if !opts.ConfigOnly { srcWorkspace := resolveWorkspace(openclawHome) dstWorkspace := resolveWorkspace(picoClawHome) if _, err := os.Stat(srcWorkspace); err == nil { wsActions, err := PlanWorkspaceMigration(srcWorkspace, dstWorkspace, force) if err != nil { return nil, nil, fmt.Errorf("planning workspace migration: %w", err) } actions = append(actions, wsActions...) } else { warnings = append(warnings, "OpenClaw workspace directory not found, skipping workspace migration") } } return actions, warnings, nil } func Execute(actions []Action, openclawHome, picoClawHome string) *Result { result := &Result{} for _, action := range actions { switch action.Type { case ActionConvertConfig: if err := executeConfigMigration(action.Source, action.Destination, picoClawHome); err != nil { result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err)) fmt.Printf(" ✗ Config migration failed: %v\n", err) } else { result.ConfigMigrated = true fmt.Printf(" ✓ Converted config: %s\n", action.Destination) } case ActionCreateDir: if err := os.MkdirAll(action.Destination, 0755); err != nil { result.Errors = append(result.Errors, err) } else { result.DirsCreated++ } case ActionBackup: bakPath := action.Destination + ".bak" if err := copyFile(action.Destination, bakPath); err != nil { result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Destination, err)) fmt.Printf(" ✗ Backup failed: %s\n", action.Destination) continue } result.BackupsCreated++ fmt.Printf(" ✓ Backed up %s -> %s.bak\n", filepath.Base(action.Destination), filepath.Base(action.Destination)) if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil { result.Errors = append(result.Errors, err) continue } if err := copyFile(action.Source, action.Destination); err != nil { result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err)) fmt.Printf(" ✗ Copy failed: %s\n", action.Source) } else { result.FilesCopied++ fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome)) } case ActionCopy: if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil { result.Errors = append(result.Errors, err) continue } if err := copyFile(action.Source, action.Destination); err != nil { result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err)) fmt.Printf(" ✗ Copy failed: %s\n", action.Source) } else { result.FilesCopied++ fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome)) } case ActionSkip: result.FilesSkipped++ } } return result } func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) error { data, err := LoadOpenClawConfig(srcConfigPath) if err != nil { return err } incoming, _, err := ConvertConfig(data) if err != nil { return err } if _, err := os.Stat(dstConfigPath); err == nil { existing, err := config.LoadConfig(dstConfigPath) if err != nil { return fmt.Errorf("loading existing PicoClaw config: %w", err) } incoming = MergeConfig(existing, incoming) } if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0755); err != nil { return err } return config.SaveConfig(dstConfigPath, incoming) } func Confirm() bool { fmt.Print("Proceed with migration? (y/n): ") var response string fmt.Scanln(&response) return strings.ToLower(strings.TrimSpace(response)) == "y" } func PrintPlan(actions []Action, warnings []string) { fmt.Println("Planned actions:") copies := 0 skips := 0 backups := 0 configCount := 0 for _, action := range actions { switch action.Type { case ActionConvertConfig: fmt.Printf(" [config] %s -> %s\n", action.Source, action.Destination) configCount++ case ActionCopy: fmt.Printf(" [copy] %s\n", filepath.Base(action.Source)) copies++ case ActionBackup: fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Destination)) backups++ copies++ case ActionSkip: if action.Description != "" { fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description) } skips++ case ActionCreateDir: fmt.Printf(" [mkdir] %s\n", action.Destination) } } if len(warnings) > 0 { fmt.Println() fmt.Println("Warnings:") for _, w := range warnings { fmt.Printf(" - %s\n", w) } } fmt.Println() fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n", copies, configCount, backups, skips) } func PrintSummary(result *Result) { fmt.Println() parts := []string{} if result.FilesCopied > 0 { parts = append(parts, fmt.Sprintf("%d files copied", result.FilesCopied)) } if result.ConfigMigrated { parts = append(parts, "1 config converted") } if result.BackupsCreated > 0 { parts = append(parts, fmt.Sprintf("%d backups created", result.BackupsCreated)) } if result.FilesSkipped > 0 { parts = append(parts, fmt.Sprintf("%d files skipped", result.FilesSkipped)) } if len(parts) > 0 { fmt.Printf("Migration complete! %s.\n", strings.Join(parts, ", ")) } else { fmt.Println("Migration complete! No actions taken.") } if len(result.Errors) > 0 { fmt.Println() fmt.Printf("%d errors occurred:\n", len(result.Errors)) for _, e := range result.Errors { fmt.Printf(" - %v\n", e) } } } func resolveOpenClawHome(override string) (string, error) { if override != "" { return expandHome(override), nil } if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" { return expandHome(envHome), nil } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolving home directory: %w", err) } return filepath.Join(home, ".openclaw"), nil } func resolvePicoClawHome(override string) (string, error) { if override != "" { return expandHome(override), nil } if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" { return expandHome(envHome), nil } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolving home directory: %w", err) } return filepath.Join(home, ".picoclaw"), nil } func resolveWorkspace(homeDir string) string { return filepath.Join(homeDir, "workspace") } func expandHome(path string) string { if path == "" { return path } if path[0] == '~' { home, _ := os.UserHomeDir() if len(path) > 1 && path[1] == '/' { return home + path[1:] } return home } return path } func backupFile(path string) error { bakPath := path + ".bak" return copyFile(path, bakPath) } func copyFile(src, dst string) error { srcFile, err := os.Open(src) if err != nil { return err } defer srcFile.Close() info, err := srcFile.Stat() if err != nil { return err } dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) if err != nil { return err } defer dstFile.Close() _, err = io.Copy(dstFile, srcFile) return err } func relPath(path, base string) string { rel, err := filepath.Rel(base, path) if err != nil { return filepath.Base(path) } return rel }