mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-06 20:37:30 +08:00
feat(runtime): add process watch patterns, unified backup/import, pluggable context engine, token usage, and codex device login
This commit is contained in:
@@ -97,6 +97,7 @@ func printHelp() {
|
||||
fmt.Println(" cron Manage scheduled tasks")
|
||||
fmt.Println(" channel Test and manage messaging channels")
|
||||
fmt.Println(" skills Manage skills (install, list, remove)")
|
||||
fmt.Println(" backup Unified backup/import for config, sessions, memory, skills")
|
||||
if tuiEnabled {
|
||||
fmt.Println(" tui Chat in terminal using the gateway chat API")
|
||||
}
|
||||
|
||||
354
cmd/cmd_backup.go
Normal file
354
cmd/cmd_backup.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type unifiedBackupManifest struct {
|
||||
Version int `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Config string `json:"config"`
|
||||
Workspace string `json:"workspace"`
|
||||
Includes []string `json:"includes,omitempty"`
|
||||
}
|
||||
|
||||
func backupCmd() {
|
||||
if len(os.Args) < 3 {
|
||||
backupHelp()
|
||||
return
|
||||
}
|
||||
switch strings.TrimSpace(os.Args[2]) {
|
||||
case "create":
|
||||
backupCreateCmd()
|
||||
case "import":
|
||||
backupImportCmd()
|
||||
default:
|
||||
fmt.Printf("Unknown backup command: %s\n", os.Args[2])
|
||||
backupHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func backupHelp() {
|
||||
fmt.Println("\nBackup commands:")
|
||||
fmt.Println(" create [archive.zip] Create unified backup (config + sessions + memory + skills)")
|
||||
fmt.Println(" import <archive.zip> Restore unified backup and auto-create rollback snapshot")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" clawgo backup create")
|
||||
fmt.Println(" clawgo backup create /tmp/clawgo-backup.zip")
|
||||
fmt.Println(" clawgo backup import /tmp/clawgo-backup.zip")
|
||||
}
|
||||
|
||||
func backupCreateCmd() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
out := ""
|
||||
if len(os.Args) >= 4 {
|
||||
out = strings.TrimSpace(os.Args[3])
|
||||
}
|
||||
if out == "" {
|
||||
out = defaultBackupPathForConfig("clawgo-backup", getConfigPath())
|
||||
}
|
||||
count, err := createUnifiedBackup(cfg.WorkspacePath(), getConfigPath(), out)
|
||||
if err != nil {
|
||||
fmt.Printf("Backup failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Backup created: %s (%d files)\n", out, count)
|
||||
}
|
||||
|
||||
func backupImportCmd() {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: clawgo backup import <archive.zip>")
|
||||
return
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
archive := strings.TrimSpace(os.Args[3])
|
||||
rollbackPath, restored, err := importUnifiedBackup(cfg.WorkspacePath(), getConfigPath(), archive)
|
||||
if err != nil {
|
||||
fmt.Printf("Import failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Import completed: %d files restored\n", restored)
|
||||
fmt.Printf("Rollback snapshot: %s\n", rollbackPath)
|
||||
}
|
||||
|
||||
func defaultBackupPath(prefix string) string {
|
||||
return defaultBackupPathForConfig(prefix, getConfigPath())
|
||||
}
|
||||
|
||||
func defaultBackupPathForConfig(prefix, configPath string) string {
|
||||
configPath = strings.TrimSpace(configPath)
|
||||
if configPath == "" {
|
||||
configPath = getConfigPath()
|
||||
}
|
||||
dir := filepath.Join(filepath.Dir(configPath), "backups")
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
name := fmt.Sprintf("%s-%s.zip", prefix, time.Now().Format("20060102-150405"))
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
|
||||
func createUnifiedBackup(workspacePath, configPath, archivePath string) (int, error) {
|
||||
workspacePath = strings.TrimSpace(workspacePath)
|
||||
configPath = strings.TrimSpace(configPath)
|
||||
archivePath = strings.TrimSpace(archivePath)
|
||||
if workspacePath == "" {
|
||||
return 0, fmt.Errorf("workspace path is empty")
|
||||
}
|
||||
if configPath == "" {
|
||||
return 0, fmt.Errorf("config path is empty")
|
||||
}
|
||||
if archivePath == "" {
|
||||
return 0, fmt.Errorf("archive path is empty")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(archivePath), 0755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
f, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
zw := zip.NewWriter(f)
|
||||
defer zw.Close()
|
||||
|
||||
agentsRoot := filepath.Join(filepath.Dir(workspacePath), "agents")
|
||||
includes := []string{
|
||||
"config/config.json",
|
||||
"workspace/MEMORY.md",
|
||||
"workspace/memory/**",
|
||||
"workspace/skills/**",
|
||||
"agents/**",
|
||||
"workspace/AGENTS.md",
|
||||
"workspace/USER.md",
|
||||
"workspace/SOUL.md",
|
||||
}
|
||||
|
||||
fileCount := 0
|
||||
seen := map[string]struct{}{}
|
||||
addFile := func(src, dst string) error {
|
||||
src = filepath.Clean(src)
|
||||
dst = filepath.ToSlash(strings.TrimSpace(dst))
|
||||
if src == "" || dst == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := seen[dst]; ok {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
r, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
hdr, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr.Name = dst
|
||||
hdr.Method = zip.Deflate
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(w, r); err != nil {
|
||||
return err
|
||||
}
|
||||
seen[dst] = struct{}{}
|
||||
fileCount++
|
||||
return nil
|
||||
}
|
||||
addTree := func(srcDir, dstDir string) error {
|
||||
info, err := os.Stat(srcDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return addFile(srcDir, filepath.Join(dstDir, filepath.Base(srcDir)))
|
||||
}
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return addFile(path, filepath.Join(dstDir, rel))
|
||||
})
|
||||
}
|
||||
|
||||
if err := addFile(configPath, "config/config.json"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := addFile(filepath.Join(workspacePath, "MEMORY.md"), "workspace/MEMORY.md"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := addTree(filepath.Join(workspacePath, "memory"), "workspace/memory"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := addTree(filepath.Join(workspacePath, "skills"), "workspace/skills"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := addTree(agentsRoot, "agents"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, name := range []string{"AGENTS.md", "USER.md", "SOUL.md"} {
|
||||
if err := addFile(filepath.Join(workspacePath, name), filepath.Join("workspace", name)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
manifest := unifiedBackupManifest{
|
||||
Version: 1,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Config: filepath.Clean(configPath),
|
||||
Workspace: filepath.Clean(workspacePath),
|
||||
Includes: includes,
|
||||
}
|
||||
manifestData, _ := json.MarshalIndent(manifest, "", " ")
|
||||
w, err := zw.Create("manifest.json")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := w.Write(manifestData); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return fileCount, nil
|
||||
}
|
||||
|
||||
func importUnifiedBackup(workspacePath, configPath, archivePath string) (string, int, error) {
|
||||
workspacePath = strings.TrimSpace(workspacePath)
|
||||
configPath = strings.TrimSpace(configPath)
|
||||
archivePath = strings.TrimSpace(archivePath)
|
||||
if workspacePath == "" || configPath == "" || archivePath == "" {
|
||||
return "", 0, fmt.Errorf("invalid import paths")
|
||||
}
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
rollbackPath := defaultBackupPathForConfig("clawgo-rollback", configPath)
|
||||
if _, err := createUnifiedBackup(workspacePath, configPath, rollbackPath); err != nil {
|
||||
return "", 0, fmt.Errorf("create rollback snapshot: %w", err)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "clawgo-import-*")
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
for _, zf := range r.File {
|
||||
target := filepath.Clean(filepath.Join(tmpDir, zf.Name))
|
||||
if !strings.HasPrefix(target, tmpDir+string(filepath.Separator)) && target != tmpDir {
|
||||
return "", 0, fmt.Errorf("invalid zip entry path: %s", zf.Name)
|
||||
}
|
||||
if zf.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(target, 0755); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
data, readErr := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
if readErr != nil {
|
||||
return "", 0, readErr
|
||||
}
|
||||
if err := os.WriteFile(target, data, zf.Mode()); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
|
||||
agentsRoot := filepath.Join(filepath.Dir(workspacePath), "agents")
|
||||
restoreTasks := []struct {
|
||||
src string
|
||||
dst string
|
||||
}{
|
||||
{src: filepath.Join(tmpDir, "workspace"), dst: workspacePath},
|
||||
{src: filepath.Join(tmpDir, "agents"), dst: agentsRoot},
|
||||
}
|
||||
sort.SliceStable(restoreTasks, func(i, j int) bool { return restoreTasks[i].src < restoreTasks[j].src })
|
||||
restored := 0
|
||||
for _, task := range restoreTasks {
|
||||
info, err := os.Stat(task.src)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return rollbackPath, restored, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(task.dst, 0755); err != nil {
|
||||
return rollbackPath, restored, err
|
||||
}
|
||||
if err := copyDirectory(task.src, task.dst); err != nil {
|
||||
return rollbackPath, restored, err
|
||||
}
|
||||
filepath.Walk(task.src, func(path string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() {
|
||||
restored++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
importedConfig := filepath.Join(tmpDir, "config", "config.json")
|
||||
if info, err := os.Stat(importedConfig); err == nil && !info.IsDir() {
|
||||
data, err := os.ReadFile(importedConfig)
|
||||
if err != nil {
|
||||
return rollbackPath, restored, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||
return rollbackPath, restored, err
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||
return rollbackPath, restored, err
|
||||
}
|
||||
restored++
|
||||
}
|
||||
|
||||
return rollbackPath, restored, nil
|
||||
}
|
||||
71
cmd/cmd_backup_test.go
Normal file
71
cmd/cmd_backup_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnifiedBackupCreateAndImport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
workspace := filepath.Join(root, "workspace")
|
||||
configPath := filepath.Join(root, "config", "config.json")
|
||||
agentsDir := filepath.Join(root, "agents", "main", "sessions")
|
||||
skillsDir := filepath.Join(workspace, "skills", "demo")
|
||||
memoryDir := filepath.Join(workspace, "memory")
|
||||
if err := os.MkdirAll(agentsDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir agents: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(skillsDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir skills: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir memory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||
t.Fatalf("mkdir config: %v", err)
|
||||
}
|
||||
_ = os.WriteFile(configPath, []byte(`{"gateway":{"token":"abc"}}`), 0644)
|
||||
_ = os.WriteFile(filepath.Join(workspace, "MEMORY.md"), []byte("long-term"), 0644)
|
||||
_ = os.WriteFile(filepath.Join(memoryDir, "2026-04-14.md"), []byte("daily-note"), 0644)
|
||||
_ = os.WriteFile(filepath.Join(skillsDir, "SKILL.md"), []byte("# demo"), 0644)
|
||||
_ = os.WriteFile(filepath.Join(agentsDir, "main.active.jsonl"), []byte("{\"type\":\"message\"}\n"), 0644)
|
||||
|
||||
archive := filepath.Join(root, "backup.zip")
|
||||
files, err := createUnifiedBackup(workspace, configPath, archive)
|
||||
if err != nil {
|
||||
t.Fatalf("createUnifiedBackup: %v", err)
|
||||
}
|
||||
if files < 4 {
|
||||
t.Fatalf("expected backup files >= 4, got %d", files)
|
||||
}
|
||||
|
||||
// Mutate files to ensure import actually restores prior state.
|
||||
_ = os.WriteFile(configPath, []byte(`{"gateway":{"token":"changed"}}`), 0644)
|
||||
_ = os.WriteFile(filepath.Join(workspace, "MEMORY.md"), []byte("changed-memory"), 0644)
|
||||
|
||||
rollback, restored, err := importUnifiedBackup(workspace, configPath, archive)
|
||||
if err != nil {
|
||||
t.Fatalf("importUnifiedBackup: %v", err)
|
||||
}
|
||||
if restored < 4 {
|
||||
t.Fatalf("expected restored files >= 4, got %d", restored)
|
||||
}
|
||||
if strings.TrimSpace(rollback) == "" {
|
||||
t.Fatalf("expected rollback path")
|
||||
}
|
||||
if _, err := os.Stat(rollback); err != nil {
|
||||
t.Fatalf("rollback snapshot missing: %v", err)
|
||||
}
|
||||
cfgData, _ := os.ReadFile(configPath)
|
||||
if !strings.Contains(string(cfgData), `"abc"`) {
|
||||
t.Fatalf("config not restored, got %s", string(cfgData))
|
||||
}
|
||||
memData, _ := os.ReadFile(filepath.Join(workspace, "MEMORY.md"))
|
||||
if strings.TrimSpace(string(memData)) != "long-term" {
|
||||
t.Fatalf("memory not restored, got %s", string(memData))
|
||||
}
|
||||
}
|
||||
@@ -406,6 +406,12 @@ func providerLoginCmd() {
|
||||
fmt.Printf("Provider %s is not configured with auth=oauth/hybrid\n", providerName)
|
||||
os.Exit(1)
|
||||
}
|
||||
oauthProvider := strings.ToLower(strings.TrimSpace(pc.OAuth.Provider))
|
||||
if oauthProvider == "codex" {
|
||||
// Codex login is device-code only; callback/browser modes are no longer used.
|
||||
manual = false
|
||||
noBrowser = true
|
||||
}
|
||||
if manual {
|
||||
noBrowser = true
|
||||
}
|
||||
@@ -460,8 +466,10 @@ func providerLoginCmd() {
|
||||
fmt.Printf("OAuth login succeeded for provider %s\n", providerName)
|
||||
if manual {
|
||||
fmt.Println("Mode: manual callback URL paste")
|
||||
} else if noBrowser {
|
||||
} else if noBrowser && oauthProvider != "codex" {
|
||||
fmt.Println("Mode: local callback listener without auto-opening browser")
|
||||
} else if oauthProvider == "codex" {
|
||||
fmt.Println("Mode: device-code")
|
||||
}
|
||||
if session.Email != "" {
|
||||
fmt.Printf("Account: %s\n", session.Email)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/YspCoder/clawgo/pkg/logger"
|
||||
)
|
||||
|
||||
var version = "0.0.2"
|
||||
var version = "1.2.0"
|
||||
var buildTime = "unknown"
|
||||
|
||||
const logo = ">"
|
||||
@@ -65,6 +65,8 @@ func main() {
|
||||
channelCmd()
|
||||
case "skills":
|
||||
skillsCmd()
|
||||
case "backup":
|
||||
backupCmd()
|
||||
case "tui":
|
||||
tuiCmd()
|
||||
case "version", "--version", "-v":
|
||||
|
||||
Reference in New Issue
Block a user