Skip to content

Commit 7e53301

Browse files
feat: add eos backup chats command for AI coding assistant data backup
Adds a new `eos backup chats` command that auto-discovers and backs up conversation history from AI coding tools (Claude Code, OpenAI Codex, Windsurf/Codeium, Cursor, Continue.dev, GitHub Copilot, Gemini, Aider) to a restic repository for data analysis and preservation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 93df31d commit 7e53301

6 files changed

Lines changed: 717 additions & 0 deletions

File tree

cmd/backup/chats.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// cmd/backup/chats.go
2+
// Command orchestration for AI chat data backup.
3+
// Business logic lives in pkg/chats/.
4+
5+
package backup
6+
7+
import (
8+
"errors"
9+
"fmt"
10+
"strings"
11+
"time"
12+
13+
"github.com/CodeMonkeyCybersecurity/eos/pkg/backup"
14+
"github.com/CodeMonkeyCybersecurity/eos/pkg/chats"
15+
eos "github.com/CodeMonkeyCybersecurity/eos/pkg/eos_cli"
16+
"github.com/CodeMonkeyCybersecurity/eos/pkg/eos_err"
17+
"github.com/CodeMonkeyCybersecurity/eos/pkg/eos_io"
18+
"github.com/spf13/cobra"
19+
"github.com/uptrace/opentelemetry-go-extra/otelzap"
20+
"go.uber.org/zap"
21+
)
22+
23+
var chatsCmd = &cobra.Command{
24+
Use: "chats",
25+
Short: "Back up AI coding assistant chat data for analysis",
26+
Long: `Back up conversation history from AI coding assistants to a restic repository.
27+
28+
Automatically discovers data from supported tools:
29+
- Claude Code (~/.claude/)
30+
- OpenAI Codex (~/.codex/)
31+
- Windsurf/Codeium (~/.codeium/)
32+
- Cursor (~/.cursor/)
33+
- Continue.dev (~/.continue/)
34+
- GitHub Copilot (~/.config/github-copilot/)
35+
- Gemini (~/.gemini/)
36+
- Aider (~/.aider/)
37+
38+
Data is backed up with tags for easy filtering and restore.
39+
Requires an existing restic repository (see: eos backup create repository).
40+
41+
Examples:
42+
# Back up all discovered chat data
43+
eos backup chats
44+
45+
# Dry run to see what would be backed up
46+
eos backup chats --dry-run
47+
48+
# Back up only specific tools
49+
eos backup chats --tool claude-code --tool codex
50+
51+
# Back up a specific user's data
52+
eos backup chats --user henry
53+
54+
# List chat backup snapshots
55+
eos backup list snapshots --tag chat-backup`,
56+
57+
RunE: eos.Wrap(func(rc *eos_io.RuntimeContext, cmd *cobra.Command, args []string) error {
58+
logger := otelzap.Ctx(rc.Ctx)
59+
60+
// Parse flags
61+
dryRun, _ := cmd.Flags().GetBool("dry-run")
62+
targetUser, _ := cmd.Flags().GetString("user")
63+
toolFilter, _ := cmd.Flags().GetStringSlice("tool")
64+
65+
// Resolve repository
66+
repoName, repoConfig, err := resolveChatsBackupRepository(rc)
67+
if err != nil {
68+
return err
69+
}
70+
71+
logger.Info("Using repository for chat backup",
72+
zap.String("repository", repoName),
73+
zap.String("backend", repoConfig.Backend),
74+
zap.String("url", repoConfig.URL))
75+
76+
// Delegate to pkg/chats business logic
77+
config := &chats.BackupConfig{
78+
RepoName: repoName,
79+
User: targetUser,
80+
Tools: toolFilter,
81+
DryRun: dryRun,
82+
}
83+
84+
result, err := chats.RunBackup(rc, config)
85+
if err != nil {
86+
if errors.Is(err, backup.ErrResticNotInstalled) {
87+
logger.Info("terminal prompt:", zap.String("output",
88+
"Restic is not installed. Install with: sudo apt-get install restic"))
89+
return eos_err.NewExpectedError(rc.Ctx, eos_err.DependencyError("restic", "back up chat data", err))
90+
}
91+
if errors.Is(err, backup.ErrRepositoryNotInitialized) {
92+
logger.Info("terminal prompt:", zap.String("output",
93+
"Restic repository not initialized. Create one with:\n"+
94+
" eos backup create repository local --path /var/lib/eos/backups"))
95+
return eos_err.NewExpectedError(rc.Ctx, err)
96+
}
97+
return err
98+
}
99+
100+
// Report results
101+
logger.Info("terminal prompt:", zap.String("output", string(result.Output)))
102+
103+
action := "Backed up"
104+
if dryRun {
105+
action = "Would back up"
106+
}
107+
logger.Info("terminal prompt:", zap.String("output",
108+
fmt.Sprintf("\n%s chat data from: %s", action, strings.Join(result.ToolsBacked, ", "))))
109+
logger.Info("terminal prompt:", zap.String("output",
110+
fmt.Sprintf("Repository: %s (%s)", repoName, repoConfig.URL)))
111+
logger.Info("terminal prompt:", zap.String("output",
112+
fmt.Sprintf("Duration: %s", result.Duration.Round(time.Millisecond))))
113+
logger.Info("terminal prompt:", zap.String("output",
114+
"Restore: eos backup restore <snapshot-id> --target /tmp/chats"))
115+
logger.Info("terminal prompt:", zap.String("output",
116+
"List: eos backup list snapshots --tag chat-backup"))
117+
118+
return nil
119+
}),
120+
}
121+
122+
// resolveChatsBackupRepository finds a repository to use for chat backups.
123+
// Reuses the same resolution logic as quick backups.
124+
func resolveChatsBackupRepository(rc *eos_io.RuntimeContext) (string, backup.Repository, error) {
125+
logger := otelzap.Ctx(rc.Ctx)
126+
127+
config, err := backup.LoadConfig(rc)
128+
if err != nil {
129+
return "", backup.Repository{}, fmt.Errorf("loading backup configuration: %w\n"+
130+
"Create a repository first: eos backup create repository local --path /var/lib/eos/backups", err)
131+
}
132+
133+
// Try default repository
134+
if repoName, err := backup.ResolveRepositoryNameFromConfig(config, ""); err == nil {
135+
repo := config.Repositories[repoName]
136+
logger.Info("Using default repository for chat backup",
137+
zap.String("repository", repoName))
138+
return repoName, repo, nil
139+
}
140+
141+
// If only one repository exists, use it
142+
if len(config.Repositories) == 1 {
143+
for name := range config.Repositories {
144+
repo := config.Repositories[name]
145+
logger.Info("Using sole configured repository for chat backup",
146+
zap.String("repository", name))
147+
return name, repo, nil
148+
}
149+
}
150+
151+
if len(config.Repositories) == 0 {
152+
return "", backup.Repository{}, fmt.Errorf("no repositories configured\n" +
153+
"Create one first: eos backup create repository local --path /var/lib/eos/backups")
154+
}
155+
156+
// Multiple repos, no default
157+
repoNames := make([]string, 0, len(config.Repositories))
158+
for name := range config.Repositories {
159+
repoNames = append(repoNames, name)
160+
}
161+
return "", backup.Repository{}, eos_err.NewExpectedError(rc.Ctx, fmt.Errorf(
162+
"multiple repositories configured (%s) but no default set; update %s",
163+
strings.Join(repoNames, ", "), backup.ConfigFile))
164+
}
165+
166+
func init() {
167+
BackupCmd.AddCommand(chatsCmd)
168+
169+
chatsCmd.Flags().Bool("dry-run", false, "Show what would be backed up without creating backup")
170+
chatsCmd.Flags().String("user", "", "Target user (default: auto-detect from SUDO_USER)")
171+
chatsCmd.Flags().StringSlice("tool", nil,
172+
fmt.Sprintf("Limit to specific tools (available: %s)", strings.Join(chats.AvailableToolNames(), ", ")))
173+
}

pkg/chats/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# pkg/chats
2+
3+
*Last Updated: 2026-03-14*
4+
5+
AI chat data discovery and backup for coding assistants.
6+
7+
## Purpose
8+
9+
Discovers and backs up conversation data from AI coding tools (Claude Code, OpenAI Codex, Windsurf, Cursor, Continue.dev, GitHub Copilot, Gemini, Aider) for data analysis, auditing, and preservation.
10+
11+
## Usage
12+
13+
```bash
14+
# Back up all discovered AI chat data
15+
eos backup chats
16+
17+
# Back up specific tools only
18+
eos backup chats --tool claude-code --tool codex
19+
20+
# Dry run
21+
eos backup chats --dry-run
22+
23+
# Specify user (default: auto-detects via SUDO_USER)
24+
eos backup chats --user henry
25+
```
26+
27+
## Package Structure
28+
29+
- `constants.go` - Tool identifiers, data paths, exclude patterns
30+
- `discover.go` - Filesystem discovery of AI tool data
31+
- `backup.go` - Restic backup orchestration (Assess/Intervene/Evaluate)
32+
33+
## Adding a New Tool
34+
35+
1. Add tool constant to `constants.go`
36+
2. Add relative path constant to `constants.go`
37+
3. Add exclude patterns if needed
38+
4. Add entry to `allTools` slice in `discover.go`

pkg/chats/backup.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// pkg/chats/backup.go
2+
// Business logic for backing up AI chat data using restic.
3+
//
4+
// RATIONALE: Follows Assess -> Intervene -> Evaluate pattern.
5+
// Uses existing backup.Client infrastructure for restic operations.
6+
7+
package chats
8+
9+
import (
10+
"fmt"
11+
"path/filepath"
12+
"strings"
13+
"time"
14+
15+
"github.com/CodeMonkeyCybersecurity/eos/pkg/backup"
16+
"github.com/CodeMonkeyCybersecurity/eos/pkg/eos_io"
17+
"github.com/uptrace/opentelemetry-go-extra/otelzap"
18+
"go.uber.org/zap"
19+
)
20+
21+
// BackupConfig holds configuration for a chat backup operation
22+
type BackupConfig struct {
23+
// RepoName is the restic repository name to use
24+
RepoName string
25+
26+
// User is the target user (empty = auto-detect via SUDO_USER)
27+
User string
28+
29+
// Tools limits backup to specific tool names (empty = all found)
30+
Tools []string
31+
32+
// DryRun shows what would be backed up without creating backup
33+
DryRun bool
34+
}
35+
36+
// BackupResult holds the result of a chat backup operation
37+
type BackupResult struct {
38+
// ToolsBacked is the list of tools that were backed up
39+
ToolsBacked []string
40+
41+
// Paths is the list of paths included in the backup
42+
Paths []string
43+
44+
// Output is the raw restic output
45+
Output string
46+
47+
// Duration is how long the backup took
48+
Duration time.Duration
49+
}
50+
51+
// RunBackup discovers AI chat data and backs it up to restic.
52+
// Follows Assess -> Intervene -> Evaluate.
53+
func RunBackup(rc *eos_io.RuntimeContext, config *BackupConfig) (*BackupResult, error) {
54+
logger := otelzap.Ctx(rc.Ctx)
55+
56+
// === ASSESS ===
57+
discovery, err := DiscoverChatData(rc, config.User)
58+
if err != nil {
59+
return nil, fmt.Errorf("discovering chat data: %w", err)
60+
}
61+
62+
tools := FilterByTools(discovery, config.Tools)
63+
if len(tools) == 0 {
64+
return nil, fmt.Errorf("no AI chat data found for user %q\n"+
65+
"Checked paths in: %s\n"+
66+
"Supported tools: %s",
67+
discovery.User, discovery.HomeDir,
68+
strings.Join(AvailableToolNames(), ", "))
69+
}
70+
71+
// Collect paths and excludes
72+
var paths []string
73+
var excludes []string
74+
var toolNames []string
75+
76+
for _, tool := range tools {
77+
paths = append(paths, tool.DataPath)
78+
toolNames = append(toolNames, tool.DisplayName)
79+
80+
// Convert relative excludes to absolute paths
81+
for _, exc := range tool.Excludes {
82+
excludes = append(excludes, filepath.Join(discovery.HomeDir, exc))
83+
}
84+
}
85+
86+
logger.Info("Chat backup plan",
87+
zap.Strings("tools", toolNames),
88+
zap.Strings("paths", paths),
89+
zap.Int("exclude_count", len(excludes)),
90+
zap.Bool("dry_run", config.DryRun))
91+
92+
// === INTERVENE ===
93+
client, err := backup.NewClient(rc, config.RepoName)
94+
if err != nil {
95+
return nil, fmt.Errorf("creating backup client: %w", err)
96+
}
97+
98+
// Build restic backup args
99+
args := []string{"backup"}
100+
args = append(args, paths...)
101+
102+
for _, exc := range excludes {
103+
args = append(args, "--exclude", exc)
104+
}
105+
106+
// Tag with metadata for easy filtering/restore
107+
args = append(args, "--tag", BackupTagPrefix)
108+
args = append(args, "--tag", fmt.Sprintf("user:%s", discovery.User))
109+
for _, tool := range tools {
110+
args = append(args, "--tag", fmt.Sprintf("%s:%s", BackupTagTool, tool.Name))
111+
}
112+
113+
if config.DryRun {
114+
args = append(args, "--dry-run")
115+
}
116+
117+
start := time.Now()
118+
output, err := client.RunRestic(args...)
119+
duration := time.Since(start)
120+
121+
if err != nil {
122+
return nil, fmt.Errorf("restic backup failed: %w", err)
123+
}
124+
125+
// === EVALUATE ===
126+
result := &BackupResult{
127+
ToolsBacked: toolNames,
128+
Paths: paths,
129+
Output: string(output),
130+
Duration: duration,
131+
}
132+
133+
logger.Info("Chat backup complete",
134+
zap.Strings("tools", toolNames),
135+
zap.Duration("duration", duration),
136+
zap.Bool("dry_run", config.DryRun))
137+
138+
return result, nil
139+
}

0 commit comments

Comments
 (0)