Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ restore it into their own agent and keep working.
Agent Capsule currently supports Codex and Claude Code export/import, including
cross-agent artifact imports through share links and zip capsules.

It also supports controlled Codex profile migration between Macs: selected
projects and their native threads, user configuration, skills, memories, and
automations can be moved while preserving the target Mac's authentication and
device identity.

Codex image uploads referenced by a session are preserved. Agent Capsule does
not package arbitrary non-image files yet.

Expand Down Expand Up @@ -126,6 +131,44 @@ capsule verify --target codex --home ~/.codex --thread <new-thread-id> --target-
capsule verify --target claude --home ~/.claude --thread <new-session-id> --target-cwd .
```

## Codex profile migration

Profile migration is separate from session handoff. It preserves selected
native thread ids and uses controlled overwrite semantics for a newly installed
target Codex. Project working trees are recreated with Git; uncommitted and
untracked files are not copied.

```bash
capsule profile export \
--target-home /Users/<target-user>/.codex \
--target-workspace /Users/<target-user>/workspace \
--project /path/to/project-a \
--git-bundle-fallback \
--out ~/.codex/profile-migrations/<migration-id>
capsule profile serve ~/.codex/profile-migrations/<migration-id> --listen :8765
```

On the target Mac:

```bash
capsule profile fetch <tokenized-source-url> --out ~/.codex/profile-migrations/<migration-id>
capsule profile clone ~/.codex/profile-migrations/<migration-id> --execute
capsule profile import ~/.codex/profile-migrations/<migration-id> --home ~/.codex
capsule profile schedule-import ~/.codex/profile-migrations/<migration-id> --home ~/.codex --execute
```

After Codex reopens:

```bash
capsule profile verify ~/.codex/profile-migrations/<migration-id> --home ~/.codex
capsule profile unschedule ~/.codex/profile-migrations/<migration-id> --home ~/.codex --execute
```

The one-shot LaunchAgent uses `KeepAlive=false`, removes its plist after the
attempt, and writes `import-status.json`. The profile allowlist excludes auth,
provider tokens, installation/device ids, cookies, Keychain data, managed
plugins, caches, logs, worktrees, and `skills/.system`.

## Privacy commitments

For link sharing, Agent Capsule encrypts the capsule locally before upload. The
Expand Down
37 changes: 37 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Agent Capsule 会把这段会话打包成一个可以一键导入的胶囊。接

Agent Capsule 目前支持 Codex 和 Claude Code 的导出/导入,也支持通过分享链接和 zip 胶囊进行跨 agent artifact 导入。

它也支持在两台 Mac 之间做受控的 Codex profile 迁移:迁移选定项目及其原生任务、用户配置、skills、memories 和 automations,同时保留目标 Mac 自己的登录态和设备身份。

Codex 会话里引用的图片上传会被保留。Agent Capsule 目前还不会打包任意非图片文件。

同源导入会创建新的原生 thread/session。跨 agent 导入会保留可见对话、工具证据、工作上下文,并把源 agent 的 raw transcript 写入 sidecar 供后续深挖;它不迁移 provider credential、登录态、云端状态、文件系统 checkpoint 或 agent 私有加密状态。
Expand Down Expand Up @@ -98,6 +100,41 @@ capsule verify --target codex --home ~/.codex --thread <new-thread-id> --target-
capsule verify --target claude --home ~/.claude --thread <new-session-id> --target-cwd .
```

## Codex Profile 迁移

Profile 迁移和单任务交接是两套语义。它会保留选中任务原来的 thread id,并对新装目标 Codex 的受控白名单做覆盖。项目工作树由 Git 在目标机器重建,不复制未提交和未跟踪文件。

源 Mac:

```bash
capsule profile discover
capsule profile export \
--target-home /Users/<target-user>/.codex \
--target-workspace /Users/<target-user>/workspace \
--project /path/to/project-a \
--git-bundle-fallback \
--out ~/.codex/profile-migrations/<migration-id>
capsule profile serve ~/.codex/profile-migrations/<migration-id> --listen :8765
```

目标 Mac:

```bash
capsule profile fetch <带令牌的源地址> --out ~/.codex/profile-migrations/<migration-id>
capsule profile clone ~/.codex/profile-migrations/<migration-id> --execute
capsule profile import ~/.codex/profile-migrations/<migration-id> --home ~/.codex
capsule profile schedule-import ~/.codex/profile-migrations/<migration-id> --home ~/.codex --execute
```

Codex 重新打开后:

```bash
capsule profile verify ~/.codex/profile-migrations/<migration-id> --home ~/.codex
capsule profile unschedule ~/.codex/profile-migrations/<migration-id> --home ~/.codex --execute
```

一次性 LaunchAgent 使用 `KeepAlive=false`,执行后会删除自己的 plist,并写入 `import-status.json`。迁移白名单明确排除认证、provider token、安装/设备 id、Cookie、Keychain、托管插件、缓存、日志、worktree 和 `skills/.system`。

## 隐私承诺

链接分享时,Agent Capsule 会先在本机加密胶囊再上传。托管服务、Worker、R2 bucket 或 S3 兼容 bucket 只会收到加密后的胶囊字节和加密后的预览 payload。没有 `#k=...` fragment key,这些服务无法解密会话内容。
Expand Down
5 changes: 4 additions & 1 deletion cmd/capsule/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ func run(args []string) error {
return runImport(args[1:])
case "verify":
return runVerify(args[1:])
case "profile":
return runProfile(args[1:])
case "help", "-h", "--help":
usage()
return nil
Expand Down Expand Up @@ -216,5 +218,6 @@ Usage:
capsule import session.capsule.zip --target claude --target-cwd . --execute
capsule import "https://example.workers.dev/s/share-id#k=..." --target codex --target-cwd . --execute
capsule verify --target codex --home ~/.codex --thread <thread-id> --target-cwd .
capsule verify --target claude --home ~/.claude --thread <session-id> --target-cwd .`)
capsule verify --target claude --home ~/.claude --thread <session-id> --target-cwd .
capsule profile help`)
}
6 changes: 6 additions & 0 deletions cmd/capsule/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ func TestHandoffCommandRemoved(t *testing.T) {
}
}

func TestProfileHelpCommand(t *testing.T) {
if err := run([]string{"profile", "help"}); err != nil {
t.Fatal(err)
}
}

func TestImportCommandOpensRestoredCodexThread(t *testing.T) {
sourceHome, threadID := createFakeCodexHome(t)
out := filepath.Join(t.TempDir(), "session.capsule.zip")
Expand Down
229 changes: 229 additions & 0 deletions cmd/capsule/profile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package main

import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/z2z23n0/agent-capsule/internal/profile"
)

type stringList []string

func (values *stringList) String() string { return strings.Join(*values, ",") }
func (values *stringList) Set(value string) error {
*values = append(*values, value)
return nil
}

func runProfile(args []string) error {
if len(args) == 0 {
profileUsage()
return flag.ErrHelp
}
switch args[0] {
case "discover":
return runProfileDiscover(args[1:])
case "export":
return runProfileExport(args[1:])
case "fetch":
return runProfileFetch(args[1:])
case "clone":
return runProfileClone(args[1:])
case "import":
return runProfileImport(args[1:])
case "verify":
return runProfileVerify(args[1:])
case "schedule-import":
return runProfileSchedule(args[1:])
case "unschedule":
return runProfileUnschedule(args[1:])
case "serve":
return runProfileServe(args[1:])
case "help", "-h", "--help":
profileUsage()
return nil
default:
return fmt.Errorf("unknown profile command %q", args[0])
}
}

func runProfileDiscover(args []string) error {
fs := flag.NewFlagSet("profile discover", flag.ContinueOnError)
home := fs.String("home", "", "source Codex home")
if err := fs.Parse(args); err != nil {
return err
}
result, err := profile.Discover(profile.DiscoverOptions{Home: *home})
if err != nil {
return err
}
return printJSON(result)
}

func runProfileExport(args []string) error {
fs := flag.NewFlagSet("profile export", flag.ContinueOnError)
home := fs.String("home", "", "source Codex home")
targetHome := fs.String("target-home", "", "target Codex home")
targetWorkspace := fs.String("target-workspace", "", "target project workspace")
out := fs.String("out", "", "output profile bundle directory")
unsafe := fs.Bool("unsafe-include-secrets", false, "allow high-confidence secrets in allowlisted profile files")
gitBundles := fs.Bool("git-bundle-fallback", false, "prepare committed Git history as a private-clone fallback")
var projects stringList
fs.Var(&projects, "project", "project root to migrate; repeat for each project")
if err := fs.Parse(args); err != nil {
return err
}
result, err := profile.Export(profile.ExportOptions{Home: *home, TargetHome: *targetHome, TargetWorkspace: *targetWorkspace, Projects: projects, Out: *out, UnsafeIncludeSecrets: *unsafe, GitBundleFallback: *gitBundles})
if err != nil {
return err
}
return printJSON(result)
}

func positionalFirst(name string, args []string, configure func(*flag.FlagSet), run func(string, *flag.FlagSet) error) error {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
configure(fs)
positional := ""
parseArgs := args
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
positional = args[0]
parseArgs = args[1:]
}
if err := fs.Parse(parseArgs); err != nil {
return err
}
if positional == "" && fs.NArg() == 1 {
positional = fs.Arg(0)
} else if fs.NArg() != 0 {
return fmt.Errorf("usage: capsule %s <source> [flags]", name)
}
if positional == "" {
return fmt.Errorf("usage: capsule %s <source> [flags]", name)
}
return run(positional, fs)
}

func runProfileFetch(args []string) error {
var out *string
var includeGitBundles *bool
return positionalFirst("profile fetch", args, func(fs *flag.FlagSet) {
out = fs.String("out", "", "target profile bundle directory")
includeGitBundles = fs.Bool("include-git-bundles", false, "fetch private-clone fallback bundles")
}, func(source string, _ *flag.FlagSet) error {
result, err := profile.Fetch(profile.FetchOptions{Source: source, Out: *out, IncludeGitBundles: *includeGitBundles})
if err != nil {
return err
}
return printJSON(result)
})
}

func runProfileClone(args []string) error {
var execute *bool
return positionalFirst("profile clone", args, func(fs *flag.FlagSet) {
execute = fs.Bool("execute", false, "clone project repositories and checkout exported commits")
}, func(bundle string, _ *flag.FlagSet) error {
result, err := profile.CloneProjects(profile.CloneOptions{BundleDir: bundle, Execute: *execute})
if err != nil {
return err
}
return printJSON(result)
})
}

func runProfileImport(args []string) error {
var home *string
var execute *bool
return positionalFirst("profile import", args, func(fs *flag.FlagSet) {
home = fs.String("home", "", "target Codex home")
execute = fs.Bool("execute", false, "apply the controlled overwrite")
}, func(bundle string, _ *flag.FlagSet) error {
result, err := profile.Import(profile.ImportOptions{BundleDir: bundle, Home: *home, Execute: *execute, RequireStopped: *execute})
if err != nil {
return err
}
return printJSON(result)
})
}

func runProfileVerify(args []string) error {
var home *string
return positionalFirst("profile verify", args, func(fs *flag.FlagSet) {
home = fs.String("home", "", "target Codex home")
}, func(bundle string, _ *flag.FlagSet) error {
result, err := profile.Verify(profile.VerifyOptions{BundleDir: bundle, Home: *home})
if err != nil {
return err
}
if err := printJSON(result); err != nil {
return err
}
if result.Status != "ok" {
return fmt.Errorf("profile verification failed")
}
return nil
})
}

func runProfileSchedule(args []string) error {
var home, cli *string
var execute *bool
return positionalFirst("profile schedule-import", args, func(fs *flag.FlagSet) {
home = fs.String("home", "", "target Codex home")
cli = fs.String("cli", "", "capsule executable path")
execute = fs.Bool("execute", false, "bootstrap the one-shot LaunchAgent")
}, func(bundle string, _ *flag.FlagSet) error {
result, err := profile.ScheduleImport(profile.ScheduleOptions{BundleDir: bundle, Home: *home, CLIPath: *cli, Submit: *execute})
if err != nil {
return err
}
return printJSON(result)
})
}

func runProfileUnschedule(args []string) error {
var home *string
var execute *bool
return positionalFirst("profile unschedule", args, func(fs *flag.FlagSet) {
home = fs.String("home", "", "target Codex home")
execute = fs.Bool("execute", false, "unload and remove the LaunchAgent plist")
}, func(bundle string, _ *flag.FlagSet) error {
result, err := profile.Unschedule(profile.UnscheduleOptions{BundleDir: bundle, Home: *home, Submit: *execute})
if err != nil {
return err
}
return printJSON(result)
})
}

func runProfileServe(args []string) error {
var listen *string
return positionalFirst("profile serve", args, func(fs *flag.FlagSet) {
listen = fs.String("listen", ":8765", "listen address")
}, func(bundle string, _ *flag.FlagSet) error {
server, err := profile.NewServer(bundle, *listen)
if err != nil {
return err
}
if err := printJSON(map[string]any{"status": "serving", "bundle_dir": filepath.Clean(bundle), "urls": server.URLs, "pid": os.Getpid()}); err != nil {
return err
}
return server.Serve()
})
}

func profileUsage() {
fmt.Fprintln(os.Stderr, `Codex profile migration commands:
capsule profile discover [--home ~/.codex]
capsule profile export --project <path>... --target-home /Users/<user>/.codex --target-workspace /Users/<user>/workspace --out <dir>
capsule profile serve <dir> --listen :8765
capsule profile fetch <dir-or-http-url> --out ~/.codex/profile-migrations/<id>
capsule profile clone <dir> [--execute]
capsule profile import <dir> --home ~/.codex [--execute]
capsule profile schedule-import <dir> --home ~/.codex [--execute]
capsule profile verify <dir> --home ~/.codex
capsule profile unschedule <dir> [--execute]`)
}
Loading
Loading