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()
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")
}

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.