Add a new `picoclaw migrate` CLI command that detects an existing OpenClaw installation and migrates workspace files and configuration to PicoClaw. Workspace markdown files (SOUL.md, AGENTS.md, USER.md, TOOLS.md, HEARTBEAT.md, memory/, skills/) are copied 1:1. Config keys are mapped from OpenClaw's camelCase JSON format to PicoClaw's snake_case format with provider and channel field mapping. Supports --dry-run, --refresh, --config-only, --workspace-only, --force flags. Existing PicoClaw files are never silently overwritten; backups are created. Closes #27 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
395 lines
9.7 KiB
Go
395 lines
9.7 KiB
Go
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
|
|
}
|