feat: add backup and restore commands

- Add backup create/list (tar.gz) with default path ~/.picoclaw/backups/
- Add backup restore with --dry-run, --force, --workspace
- Map picoclaw/* to ~/.picoclaw, workspace/* to config workspace
- Add backup_cmd_test.go with roundtrip and option tests
- Add docs/backup-restore.md with usage, importance, and scenarios

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
YS Liu 2026-02-24 18:06:30 +08:00
parent 8774526616
commit 0e5ccad073
4 changed files with 912 additions and 0 deletions

550
cmd/picoclaw/backup_cmd.go Normal file
View file

@ -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 <archive> Restore from a backup archive")
fmt.Println()
fmt.Println("Create options:")
fmt.Println(" -o, --output <path> 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 <path> 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 <archive> [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 <path> 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
}

View file

@ -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
}

View file

@ -115,6 +115,8 @@ func main() {
authCmd() authCmd()
case "cron": case "cron":
cronCmd() cronCmd()
case "backup":
backupCmd()
case "skills": case "skills":
if len(os.Args) < 3 { if len(os.Args) < 3 {
skillsHelp() skillsHelp()
@ -185,6 +187,7 @@ func printHelp() {
fmt.Println(" status Show picoclaw status") fmt.Println(" status Show picoclaw status")
fmt.Println(" cron Manage scheduled tasks") fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") 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(" skills Manage skills (install, list, remove)")
fmt.Println(" version Show version information") fmt.Println(" version Show version information")
} }

151
docs/backup-restore.md Normal file
View file

@ -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 <archive>` — 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 agents 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 <path> <archive>` to restore the archive and align config and workspace.
- **After accidental overwrite or deletion**: Restore from your latest backup with `picoclaw backup restore <archive>`. 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 <path>` — Write the archive to this path (default: `~/.picoclaw/backups/picoclaw-backup-<timestamp>.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 <archive> [options]`
- **Options**:
- `--dry-run` — Print what would be restored without writing files.
- `--force` — Overwrite existing files. Without this, existing paths are skipped.
- `--workspace <path>` — 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.<timestamp>`. 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.