diff --git a/cmd/picoclaw/backup_cmd.go b/cmd/picoclaw/backup_cmd.go new file mode 100644 index 000000000..0de3c82ba --- /dev/null +++ b/cmd/picoclaw/backup_cmd.go @@ -0,0 +1,550 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +const ( + archivePrefixPicoclaw = "picoclaw/" + archivePrefixWorkspace = "workspace/" +) + +type backupOptions struct { + OutputPath string + WithSessions bool +} + +type restoreOptions struct { + DryRun bool + Force bool + Workspace string +} + +type backupEntry struct { + SourcePath string + ArchivePath string +} + +func backupCmd() { + args := os.Args[2:] + if len(args) == 0 { + backupCreateCmd(nil) + return + } + + switch args[0] { + case "create": + backupCreateCmd(args[1:]) + case "list": + backupListCmd(args[1:]) + case "restore": + backupRestoreCmd(args[1:]) + case "help", "--help", "-h": + backupHelp() + default: + fmt.Printf("Unknown backup command: %s\n", args[0]) + backupHelp() + } +} + +func backupHelp() { + fmt.Println("\nBackup commands:") + fmt.Println(" create Create a backup archive (default)") + fmt.Println(" list Show files/directories that would be backed up") + fmt.Println(" restore Restore from a backup archive") + fmt.Println() + fmt.Println("Create options:") + fmt.Println(" -o, --output Output tar.gz path") + fmt.Println(" --with-sessions Include workspace/sessions in backup") + fmt.Println() + fmt.Println("Restore options:") + fmt.Println(" --dry-run Print what would be restored without writing files") + fmt.Println(" --force Overwrite existing files") + fmt.Println(" --workspace Restore workspace to this directory (default: from config)") + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" picoclaw backup create") + fmt.Println(" picoclaw backup list") + fmt.Println(" picoclaw backup create --with-sessions") + fmt.Println(" picoclaw backup create --output ~/Desktop/picoclaw-backup.tar.gz") + fmt.Println(" picoclaw backup restore ~/.picoclaw/backups/picoclaw-backup-20260101-120000.tar.gz") + fmt.Println(" picoclaw backup restore backup.tar.gz --dry-run") + fmt.Println(" picoclaw backup restore backup.tar.gz --workspace ~/my-workspace --force") +} + +func backupCreateCmd(args []string) { + opts, showHelp, err := parseBackupOptions(args) + if err != nil { + fmt.Printf("Error: %v\n", err) + backupHelp() + return + } + if showHelp { + backupHelp() + return + } + + cfg, err := loadConfig() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + os.Exit(1) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error resolving home directory: %v\n", err) + os.Exit(1) + } + + entries := collectBackupEntries(cfg, homeDir, opts.WithSessions) + if len(entries) == 0 { + fmt.Println("No backup targets found. Run onboard first, then try again.") + return + } + + if opts.OutputPath == "" { + opts.OutputPath = defaultBackupPath(homeDir) + } + opts.OutputPath = expandHomePath(opts.OutputPath, homeDir) + + if err := os.MkdirAll(filepath.Dir(opts.OutputPath), 0755); err != nil { + fmt.Printf("Error creating backup directory: %v\n", err) + os.Exit(1) + } + + if err := createBackupArchive(opts.OutputPath, entries); err != nil { + fmt.Printf("Error creating backup archive: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Backup created: %s\n", opts.OutputPath) + fmt.Printf(" Included %d path(s)\n", len(entries)) +} + +func backupListCmd(args []string) { + opts, showHelp, err := parseBackupOptions(args) + if err != nil { + fmt.Printf("Error: %v\n", err) + backupHelp() + return + } + if showHelp { + backupHelp() + return + } + + cfg, err := loadConfig() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + os.Exit(1) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error resolving home directory: %v\n", err) + os.Exit(1) + } + + entries := collectBackupEntries(cfg, homeDir, opts.WithSessions) + if len(entries) == 0 { + fmt.Println("No backup targets found.") + return + } + + fmt.Println("\nBackup targets:") + fmt.Println("---------------") + for _, entry := range entries { + fmt.Printf(" %s -> %s\n", entry.SourcePath, entry.ArchivePath) + } +} + +func backupRestoreCmd(args []string) { + opts, archivePath, showHelp, err := parseRestoreOptions(args) + if err != nil { + fmt.Printf("Error: %v\n", err) + backupHelp() + return + } + if showHelp { + backupHelp() + return + } + if archivePath == "" { + fmt.Println("Error: restore requires an archive path") + fmt.Println("Usage: picoclaw backup restore [options]") + os.Exit(1) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error resolving home directory: %v\n", err) + os.Exit(1) + } + + baseDir := filepath.Join(homeDir, ".picoclaw") + workspaceDir := opts.Workspace + if workspaceDir == "" { + cfg, err := loadConfig() + if err != nil { + fmt.Println("Error: no config found. Either run 'picoclaw onboard' first or use --workspace to specify where to restore workspace files.") + os.Exit(1) + } + workspaceDir = cfg.WorkspacePath() + } else { + workspaceDir = expandHomePath(workspaceDir, homeDir) + } + + if err := extractBackupArchive(archivePath, baseDir, workspaceDir, opts); err != nil { + fmt.Printf("Error restoring backup: %v\n", err) + os.Exit(1) + } + + if opts.DryRun { + fmt.Println("Dry run complete. No files were written.") + } else { + fmt.Println("Restore complete.") + } +} + +func parseBackupOptions(args []string) (backupOptions, bool, error) { + opts := backupOptions{} + for i := 0; i < len(args); i++ { + switch args[i] { + case "--with-sessions": + opts.WithSessions = true + case "-o", "--output": + if i+1 >= len(args) { + return opts, false, fmt.Errorf("%s requires a value", args[i]) + } + opts.OutputPath = args[i+1] + i++ + case "help", "--help", "-h": + return opts, true, nil + default: + return opts, false, fmt.Errorf("unknown option: %s", args[i]) + } + } + return opts, false, nil +} + +func parseRestoreOptions(args []string) (restoreOptions, string, bool, error) { + opts := restoreOptions{} + var archivePath string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--dry-run": + opts.DryRun = true + case "--force": + opts.Force = true + case "--workspace": + if i+1 >= len(args) { + return opts, "", false, fmt.Errorf("--workspace requires a value") + } + opts.Workspace = args[i+1] + i++ + case "help", "--help", "-h": + return opts, "", true, nil + default: + if strings.HasPrefix(args[i], "-") { + return opts, "", false, fmt.Errorf("unknown option: %s", args[i]) + } + if archivePath == "" { + archivePath = args[i] + } + } + } + return opts, archivePath, false, nil +} + +func defaultBackupPath(homeDir string) string { + timestamp := time.Now().UTC().Format("20060102-150405") + return filepath.Join(homeDir, ".picoclaw", "backups", fmt.Sprintf("picoclaw-backup-%s.tar.gz", timestamp)) +} + +func expandHomePath(path string, homeDir string) string { + if path == "~" { + return homeDir + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(homeDir, path[2:]) + } + return path +} + +func collectBackupEntries(cfg *config.Config, homeDir string, withSessions bool) []backupEntry { + baseDir := filepath.Join(homeDir, ".picoclaw") + workspace := cfg.WorkspacePath() + + candidates := []backupEntry{ + { + SourcePath: filepath.Join(baseDir, "config.json"), + ArchivePath: filepath.ToSlash(filepath.Join("picoclaw", "config.json")), + }, + { + SourcePath: filepath.Join(baseDir, "auth.json"), + ArchivePath: filepath.ToSlash(filepath.Join("picoclaw", "auth.json")), + }, + { + SourcePath: filepath.Join(workspace, "AGENTS.md"), + ArchivePath: filepath.ToSlash(filepath.Join("workspace", "AGENTS.md")), + }, + { + SourcePath: filepath.Join(workspace, "HOOKS.md"), + ArchivePath: filepath.ToSlash(filepath.Join("workspace", "HOOKS.md")), + }, + { + SourcePath: filepath.Join(workspace, "IDENTITY.md"), + ArchivePath: filepath.ToSlash(filepath.Join("workspace", "IDENTITY.md")), + }, + { + SourcePath: filepath.Join(workspace, "SOUL.md"), + ArchivePath: filepath.ToSlash(filepath.Join("workspace", "SOUL.md")), + }, + { + SourcePath: filepath.Join(workspace, "TOOLS.md"), + ArchivePath: filepath.ToSlash(filepath.Join("workspace", "TOOLS.md")), + }, + { + SourcePath: filepath.Join(workspace, "USER.md"), + ArchivePath: filepath.ToSlash(filepath.Join("workspace", "USER.md")), + }, + { + SourcePath: filepath.Join(workspace, "memory"), + ArchivePath: filepath.ToSlash(filepath.Join("workspace", "memory")), + }, + { + SourcePath: filepath.Join(workspace, "skills"), + ArchivePath: filepath.ToSlash(filepath.Join("workspace", "skills")), + }, + { + SourcePath: filepath.Join(workspace, "cron"), + ArchivePath: filepath.ToSlash(filepath.Join("workspace", "cron")), + }, + } + + if withSessions { + candidates = append(candidates, backupEntry{ + SourcePath: filepath.Join(workspace, "sessions"), + ArchivePath: filepath.ToSlash(filepath.Join("workspace", "sessions")), + }) + } + + existing := make([]backupEntry, 0, len(candidates)) + for _, entry := range candidates { + if _, err := os.Stat(entry.SourcePath); err == nil { + existing = append(existing, entry) + } + } + return existing +} + +func createBackupArchive(outputPath string, entries []backupEntry) error { + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + + gzw := gzip.NewWriter(file) + defer gzw.Close() + + tw := tar.NewWriter(gzw) + defer tw.Close() + + for _, entry := range entries { + info, err := os.Stat(entry.SourcePath) + if err != nil { + return err + } + if info.IsDir() { + if err := addDirectoryToArchive(tw, entry.SourcePath, entry.ArchivePath); err != nil { + return err + } + continue + } + if err := addFileToArchive(tw, entry.SourcePath, entry.ArchivePath); err != nil { + return err + } + } + + return nil +} + +func addDirectoryToArchive(tw *tar.Writer, sourceDir, archiveRoot string) error { + return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return err + } + + target := archiveRoot + if relPath != "." { + target = filepath.Join(archiveRoot, relPath) + } + target = filepath.ToSlash(target) + + if info.IsDir() { + return addDirHeaderToArchive(tw, info, target) + } + if !info.Mode().IsRegular() { + return nil + } + + return addFileToArchive(tw, path, target) + }) +} + +func addDirHeaderToArchive(tw *tar.Writer, info os.FileInfo, archivePath string) error { + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = strings.TrimSuffix(archivePath, "/") + "/" + return tw.WriteHeader(header) +} + +func addFileToArchive(tw *tar.Writer, sourcePath, archivePath string) error { + info, err := os.Stat(sourcePath) + if err != nil { + return err + } + + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = archivePath + + if err := tw.WriteHeader(header); err != nil { + return err + } + + file, err := os.Open(sourcePath) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(tw, file) + return err +} + +// archivePathToDest maps an archive path (e.g. picoclaw/config.json or workspace/AGENTS.md) +// to the destination path on disk. Returns empty string if the path is not under a known prefix. +func archivePathToDest(archivePath, baseDir, workspaceDir string) string { + archivePath = filepath.ToSlash(archivePath) + if strings.HasPrefix(archivePath, archivePrefixPicoclaw) { + rel := strings.TrimPrefix(archivePath, archivePrefixPicoclaw) + return filepath.Join(baseDir, filepath.FromSlash(rel)) + } + if strings.HasPrefix(archivePath, archivePrefixWorkspace) { + rel := strings.TrimPrefix(archivePath, archivePrefixWorkspace) + return filepath.Join(workspaceDir, filepath.FromSlash(rel)) + } + return "" +} + +func extractBackupArchive(archivePath, baseDir, workspaceDir string, opts restoreOptions) error { + home, _ := os.UserHomeDir() + archivePath = expandHomePath(archivePath, home) + + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + + gzr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + restored := 0 + skipped := 0 + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + name := filepath.ToSlash(hdr.Name) + // Skip entries not under our known prefixes + dest := archivePathToDest(name, baseDir, workspaceDir) + if dest == "" { + continue + } + + if hdr.Typeflag == tar.TypeDir { + if opts.DryRun { + fmt.Printf(" [dir] %s\n", name) + restored++ + continue + } + if _, err := os.Stat(dest); err == nil && !opts.Force { + skipped++ + continue + } + if err := os.MkdirAll(dest, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dest, err) + } + restored++ + continue + } + + if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { + continue + } + + if opts.DryRun { + fmt.Printf(" [file] %s -> %s\n", name, dest) + restored++ + continue + } + + if _, err := os.Stat(dest); err == nil && !opts.Force { + skipped++ + continue + } + + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(dest), err) + } + + mode := hdr.FileInfo().Mode() + out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("create %s: %w", dest, err) + } + _, err = io.Copy(out, tr) + out.Close() + if err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + restored++ + } + + if !opts.DryRun && skipped > 0 { + fmt.Printf(" Skipped %d existing path(s) (use --force to overwrite)\n", skipped) + } + fmt.Printf(" Restored %d path(s)\n", restored) + return nil +} diff --git a/cmd/picoclaw/backup_cmd_test.go b/cmd/picoclaw/backup_cmd_test.go new file mode 100644 index 000000000..b4ce6dd51 --- /dev/null +++ b/cmd/picoclaw/backup_cmd_test.go @@ -0,0 +1,208 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestParseBackupOptions(t *testing.T) { + opts, showHelp, err := parseBackupOptions([]string{"--with-sessions", "-o", "~/x.tar.gz"}) + if err != nil { + t.Fatalf("parseBackupOptions returned error: %v", err) + } + if showHelp { + t.Fatalf("expected showHelp=false") + } + if !opts.WithSessions { + t.Fatalf("expected WithSessions=true") + } + if opts.OutputPath != "~/x.tar.gz" { + t.Fatalf("unexpected OutputPath: %q", opts.OutputPath) + } +} + +func TestParseBackupOptionsHelp(t *testing.T) { + _, showHelp, err := parseBackupOptions([]string{"--help"}) + if err != nil { + t.Fatalf("parseBackupOptions returned error: %v", err) + } + if !showHelp { + t.Fatalf("expected showHelp=true for --help") + } +} + +func TestParseRestoreOptions(t *testing.T) { + opts, archive, showHelp, err := parseRestoreOptions([]string{"backup.tar.gz", "--dry-run", "--force"}) + if err != nil { + t.Fatalf("parseRestoreOptions returned error: %v", err) + } + if showHelp { + t.Fatalf("expected showHelp=false") + } + if archive != "backup.tar.gz" { + t.Fatalf("unexpected archive path: %q", archive) + } + if !opts.DryRun || !opts.Force { + t.Fatalf("expected DryRun and Force true, got DryRun=%v Force=%v", opts.DryRun, opts.Force) + } +} + +func TestParseRestoreOptionsWorkspace(t *testing.T) { + opts, archive, _, err := parseRestoreOptions([]string{"x.tar.gz", "--workspace", "~/my-ws"}) + if err != nil { + t.Fatalf("parseRestoreOptions: %v", err) + } + if archive != "x.tar.gz" || opts.Workspace != "~/my-ws" { + t.Fatalf("archive=%q workspace=%q", archive, opts.Workspace) + } +} + +func TestCollectBackupEntries(t *testing.T) { + homeDir := t.TempDir() + workspace := filepath.Join(homeDir, "workspace") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + + mustWriteFile(t, filepath.Join(homeDir, ".picoclaw", "config.json"), "{}") + mustWriteFile(t, filepath.Join(homeDir, ".picoclaw", "auth.json"), "{}") + mustWriteFile(t, filepath.Join(workspace, "AGENTS.md"), "# AGENTS") + mustWriteFile(t, filepath.Join(workspace, "HOOKS.md"), "# HOOKS") + mustWriteFile(t, filepath.Join(workspace, "IDENTITY.md"), "# IDENTITY") + mustWriteFile(t, filepath.Join(workspace, "SOUL.md"), "# SOUL") + mustWriteFile(t, filepath.Join(workspace, "TOOLS.md"), "# TOOLS") + mustWriteFile(t, filepath.Join(workspace, "USER.md"), "# USER") + if err := os.MkdirAll(filepath.Join(workspace, "memory"), 0755); err != nil { + t.Fatalf("mkdir memory: %v", err) + } + if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0755); err != nil { + t.Fatalf("mkdir skills: %v", err) + } + if err := os.MkdirAll(filepath.Join(workspace, "cron"), 0755); err != nil { + t.Fatalf("mkdir cron: %v", err) + } + if err := os.MkdirAll(filepath.Join(workspace, "sessions"), 0755); err != nil { + t.Fatalf("mkdir sessions: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + }, + }, + } + + entriesNoSessions := collectBackupEntries(cfg, homeDir, false) + if !hasArchivePath(entriesNoSessions, "workspace/AGENTS.md") { + t.Fatalf("expected workspace/AGENTS.md in backup entries") + } + if hasArchivePath(entriesNoSessions, "workspace/sessions") { + t.Fatalf("did not expect workspace/sessions without --with-sessions") + } + + entriesWithSessions := collectBackupEntries(cfg, homeDir, true) + if !hasArchivePath(entriesWithSessions, "workspace/sessions") { + t.Fatalf("expected workspace/sessions with --with-sessions") + } +} + +func TestBackupRestoreRoundtrip(t *testing.T) { + homeDir := t.TempDir() + workspace := filepath.Join(homeDir, "workspace") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + + mustWriteFile(t, filepath.Join(homeDir, ".picoclaw", "config.json"), `{"agents":{"defaults":{"workspace":"`+workspace+`"}}}`) + mustWriteFile(t, filepath.Join(homeDir, ".picoclaw", "auth.json"), `{}`) + mustWriteFile(t, filepath.Join(workspace, "AGENTS.md"), "# AGENTS content") + mustWriteFile(t, filepath.Join(workspace, "USER.md"), "# USER content") + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + }, + }, + } + + entries := collectBackupEntries(cfg, homeDir, false) + if len(entries) < 4 { + t.Fatalf("expected at least 4 entries, got %d", len(entries)) + } + + archivePath := filepath.Join(t.TempDir(), "backup.tar.gz") + if err := createBackupArchive(archivePath, entries); err != nil { + t.Fatalf("createBackupArchive: %v", err) + } + + restoreBase := t.TempDir() + restoreWorkspace := filepath.Join(restoreBase, "restored-ws") + if err := os.MkdirAll(restoreWorkspace, 0755); err != nil { + t.Fatalf("mkdir restore workspace: %v", err) + } + + opts := restoreOptions{Force: true} + if err := extractBackupArchive(archivePath, filepath.Join(restoreBase, ".picoclaw"), restoreWorkspace, opts); err != nil { + t.Fatalf("extractBackupArchive: %v", err) + } + + // Verify restored files + configPath := filepath.Join(restoreBase, ".picoclaw", "config.json") + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("restored config.json missing: %v", err) + } + agentsPath := filepath.Join(restoreWorkspace, "AGENTS.md") + data, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatalf("reading restored AGENTS.md: %v", err) + } + if string(data) != "# AGENTS content" { + t.Errorf("AGENTS.md content = %q, want %q", string(data), "# AGENTS content") + } + userPath := filepath.Join(restoreWorkspace, "USER.md") + data, err = os.ReadFile(userPath) + if err != nil { + t.Fatalf("reading restored USER.md: %v", err) + } + if string(data) != "# USER content" { + t.Errorf("USER.md content = %q, want %q", string(data), "# USER content") + } +} + +func TestArchivePathToDest(t *testing.T) { + base := "/home/.picoclaw" + ws := "/home/workspace" + if got := archivePathToDest("picoclaw/config.json", base, ws); got != filepath.Join(base, "config.json") { + t.Errorf("picoclaw/config.json -> %q", got) + } + if got := archivePathToDest("workspace/AGENTS.md", base, ws); got != filepath.Join(ws, "AGENTS.md") { + t.Errorf("workspace/AGENTS.md -> %q", got) + } + if got := archivePathToDest("unknown/foo", base, ws); got != "" { + t.Errorf("unknown prefix should return empty, got %q", got) + } +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("mkdir parent for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func hasArchivePath(entries []backupEntry, archivePath string) bool { + for _, entry := range entries { + if entry.ArchivePath == archivePath { + return true + } + } + return false +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 25ad701ca..31ee1a75c 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -115,6 +115,8 @@ func main() { authCmd() case "cron": cronCmd() + case "backup": + backupCmd() case "skills": if len(os.Args) < 3 { skillsHelp() @@ -185,6 +187,7 @@ func printHelp() { fmt.Println(" status Show picoclaw status") fmt.Println(" cron Manage scheduled tasks") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") + fmt.Println(" backup Backup and restore config/workspace (create, list, restore)") fmt.Println(" skills Manage skills (install, list, remove)") fmt.Println(" version Show version information") } diff --git a/docs/backup-restore.md b/docs/backup-restore.md new file mode 100644 index 000000000..90c05b815 --- /dev/null +++ b/docs/backup-restore.md @@ -0,0 +1,151 @@ +# Backup and Restore + +This document describes the PicoClaw CLI backup and restore commands (`picoclaw backup` and `picoclaw backup restore`). Other backup-related behavior (e.g. `onboard --force` creating a timestamped config backup, or `migrate` creating `.bak` files before overwriting) is mentioned only briefly; those do not have a dedicated restore command and require manual handling if you need to revert. + +--- + +## 1. Overview + +The backup feature creates a single compressed archive (tar.gz) of your PicoClaw config and workspace. Restore unpacks that archive back to the expected locations so you can recover after migration, reinstall, or accidental changes. + +- **Backup**: `picoclaw backup` or `picoclaw backup create` — creates an archive. +- **List**: `picoclaw backup list` — shows which paths would be included (no archive is written). +- **Restore**: `picoclaw backup restore ` — restores from an archive. + +--- + +## 2. Why Backups Matter + +- **Config and credentials**: `config.json` and `auth.json` hold model settings, channels, and authentication. Losing them means reconfiguring or logging in again. +- **Workspace assets**: Files like AGENTS.md, IDENTITY.md, SOUL.md, and USER.md define your agent’s role and preferences. The `memory`, `skills`, and `cron` directories hold long-lived content that is hard to recreate. +- **Recoverability**: Regular backups let you quickly revert after mistakes, failed upgrades, or when moving to a new machine, instead of starting from scratch. + +--- + +## 3. Use Cases + +- **Before upgrades or big changes**: Run `picoclaw backup create`, then perform the upgrade or `picoclaw onboard --force`. If something goes wrong, use `picoclaw backup restore` to roll back. +- **Moving to a new machine**: On the new machine, run `picoclaw onboard` (or create the directories), then `picoclaw backup restore --workspace ` to restore the archive and align config and workspace. +- **After accidental overwrite or deletion**: Restore from your latest backup with `picoclaw backup restore `. Use `--dry-run` first to see what would be restored. +- **Sharing or cloning an environment**: Copy the archive to a USB drive or cloud storage, then on another machine run restore to reproduce the same config and workspace (keep credentials secure). +- **Regular snapshots**: Use cron or a manual habit to run `picoclaw backup create -o ~/backups/picoclaw-YYYYMMDD.tar.gz` so you have dated snapshots to fall back on. + +--- + +## 4. Default Storage Location + +- **Default backup directory**: `~/.picoclaw/backups/` +- **Default filename pattern**: `picoclaw-backup-{YYYYMMDD-HHMMSS}.tar.gz` (UTC timestamp) +- You can override the path with `-o` or `--output`, e.g. `picoclaw backup create -o ~/Desktop/my-backup.tar.gz` + +--- + +## 5. Backup Command + +### Create + +- **Usage**: `picoclaw backup` or `picoclaw backup create [options]` +- **Options**: + - `-o`, `--output ` — Write the archive to this path (default: `~/.picoclaw/backups/picoclaw-backup-.tar.gz`). + - `--with-sessions` — Include the `workspace/sessions` directory in the backup. + +### List + +- **Usage**: `picoclaw backup list [options]` +- Prints the local paths and their archive paths that would be included in a backup. No archive is created. + +### What Gets Backed Up + +Only paths that exist on disk are included: + +- **Config**: `~/.picoclaw/config.json`, `~/.picoclaw/auth.json` +- **Workspace files**: AGENTS.md, HOOKS.md, IDENTITY.md, SOUL.md, TOOLS.md, USER.md +- **Workspace directories**: `memory/`, `skills/`, `cron/` +- **Optional**: `sessions/` (only with `--with-sessions`) + +### Examples + +```bash +# Create backup with default path +picoclaw backup create + +# Create backup with custom path +picoclaw backup create -o ~/Desktop/picoclaw-backup.tar.gz + +# Include sessions +picoclaw backup create --with-sessions + +# See what would be backed up +picoclaw backup list +``` + +--- + +## 6. Restore Command + +- **Usage**: `picoclaw backup restore [options]` +- **Options**: + - `--dry-run` — Print what would be restored without writing files. + - `--force` — Overwrite existing files. Without this, existing paths are skipped. + - `--workspace ` — Restore workspace contents into this directory. If omitted, the workspace path from the current config is used (or you must have run `picoclaw onboard` first). + +Restore maps archive paths to the current environment: + +- `picoclaw/*` → `~/.picoclaw/*` +- `workspace/*` → current workspace directory (from config or `--workspace`) + +If there is no config (e.g. fresh machine), you must pass `--workspace` so the command knows where to put workspace files; config files will still be restored under `~/.picoclaw/`. + +### Examples + +```bash +# Restore to current config locations +picoclaw backup restore ~/.picoclaw/backups/picoclaw-backup-20260101-120000.tar.gz + +# Preview only +picoclaw backup restore backup.tar.gz --dry-run + +# Restore workspace to a different directory and overwrite existing files +picoclaw backup restore backup.tar.gz --workspace ~/my-workspace --force +``` + +--- + +## 7. Archive Format (for scripts or debugging) + +The archive is gzip-compressed tar. Paths inside use forward slashes and two top-level prefixes: + +- `picoclaw/` — config and auth (e.g. `picoclaw/config.json`, `picoclaw/auth.json`) +- `workspace/` — workspace files and directories (e.g. `workspace/AGENTS.md`, `workspace/memory/`, `workspace/skills/`) + +Example layout: + +``` +picoclaw/config.json +picoclaw/auth.json +workspace/AGENTS.md +workspace/HOOKS.md +workspace/IDENTITY.md +workspace/SOUL.md +workspace/TOOLS.md +workspace/USER.md +workspace/memory/ +workspace/skills/ +workspace/cron/ +workspace/sessions/ (only if created with --with-sessions) +``` + +--- + +## 8. Other Backup-Related Behavior + +- **`picoclaw onboard --force`**: Before overwriting, backs up the existing config to `config.json.bak.`. There is no CLI restore for this; copy the file back manually if needed. +- **`picoclaw migrate`**: When copying over existing files, creates a `.bak` copy first. Again, no CLI restore; restore those files manually if required. + +--- + +## 9. FAQ + +- **After restore, do I need to restart anything?** If you use the gateway or a background service, restart it so it picks up the restored config and workspace. +- **Are credentials safe?** `auth.json` can contain tokens; treat backup archives as sensitive and store them securely. +- **Restoring on a different machine?** Use `--workspace` if the workspace path differs, and ensure the restored `config.json`’s workspace path is correct for the new machine.