Skip to content

Commit e846234

Browse files
lroolleclaude
andcommitted
feat(fork): add ccx fork command for cross-project session resume
Copy a Claude Code session to a different project directory with a new session ID, enabling `claude --resume` from any project. - Rewrites sessionId and cwd on all records - Drops file-history-snapshot records (sidecar files don't exist for fork) - Nulls out worktree-state to prevent chdir to original worktree - Canonicalizes target path via EvalSymlinks (matches Claude Code's realpath) - Uses Claude Code's exact sanitizePath encoding (keeps leading dash) - 6 new tests covering rewrite, drop, null, chain preservation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0c56033 commit e846234

4 files changed

Lines changed: 388 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ ccx sessions --after=2026-03-01 # Date filtered
7171
ccx view [session] # View in terminal
7272
ccx export -f html --brief # Export conversation-only HTML
7373
ccx search "auth bug" # Search across sessions + memory
74+
ccx fork abc123 # Fork session to current project
7475
ccx doctor # Check setup
7576
```
7677

internal/cmd/fork.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"regexp"
12+
13+
"github.com/google/uuid"
14+
"github.com/spf13/cobra"
15+
16+
"github.com/thevibeworks/ccx/internal/config"
17+
"github.com/thevibeworks/ccx/internal/provider"
18+
)
19+
20+
var forkCmd = &cobra.Command{
21+
Use: "fork <session-id>",
22+
Short: "Fork a session to the current project for resuming",
23+
Long: `Copy a session from any project into the current project directory,
24+
rewriting the session ID and CWD so you can resume it with 'claude --resume'.
25+
26+
The original session is never modified. The forked copy gets a new UUID
27+
and points to the target directory.
28+
29+
Examples:
30+
ccx fork abc12345 # Fork to current directory
31+
ccx fork abc12345 --to /path # Fork to specific directory`,
32+
Args: cobra.ExactArgs(1),
33+
RunE: runFork,
34+
}
35+
36+
var forkTo string
37+
38+
func init() {
39+
forkCmd.Flags().StringVar(&forkTo, "to", "", "target directory (default: current working directory)")
40+
rootCmd.AddCommand(forkCmd)
41+
}
42+
43+
func runFork(cmd *cobra.Command, args []string) error {
44+
sessionQuery := args[0]
45+
backend := provider.Default()
46+
47+
session, err := backend.FindSession("", sessionQuery)
48+
if err != nil {
49+
return fmt.Errorf("failed to search sessions: %w", err)
50+
}
51+
if session == nil {
52+
return fmt.Errorf("session not found: %s", sessionQuery)
53+
}
54+
55+
if session.Provider == "codex" {
56+
return fmt.Errorf("fork only supports Claude Code sessions (codex uses a different format)")
57+
}
58+
59+
targetDir := forkTo
60+
if targetDir == "" {
61+
targetDir, err = os.Getwd()
62+
if err != nil {
63+
return fmt.Errorf("failed to get current directory: %w", err)
64+
}
65+
}
66+
targetDir, err = filepath.Abs(targetDir)
67+
if err != nil {
68+
return fmt.Errorf("invalid target directory: %w", err)
69+
}
70+
// Canonicalize via EvalSymlinks to match Claude Code's realpath resolution
71+
if resolved, err := filepath.EvalSymlinks(targetDir); err == nil {
72+
targetDir = resolved
73+
}
74+
75+
settings := config.Load()
76+
encodedTarget := claudeSanitizePath(targetDir)
77+
targetProjectDir := filepath.Join(settings.ClaudeHome, "projects", encodedTarget)
78+
if err := os.MkdirAll(targetProjectDir, 0755); err != nil {
79+
return fmt.Errorf("failed to create target project directory: %w", err)
80+
}
81+
82+
newSessionID := uuid.New().String()
83+
targetPath := filepath.Join(targetProjectDir, newSessionID+".jsonl")
84+
85+
result, err := forkSession(session.FilePath, targetPath, newSessionID, targetDir)
86+
if err != nil {
87+
return err
88+
}
89+
lineCount := result.lineCount
90+
91+
srcID := session.ID
92+
if len(srcID) > 8 {
93+
srcID = srcID[:8]
94+
}
95+
newIDShort := newSessionID[:8]
96+
97+
fmt.Printf("Forked session %s -> %s (%d lines)\n", srcID, newIDShort, lineCount)
98+
fmt.Printf(" Source: %s\n", session.FilePath)
99+
fmt.Printf(" Target: %s\n", targetPath)
100+
fmt.Println()
101+
fmt.Println("Resume with:")
102+
fmt.Printf(" cd %s && claude --resume %s\n", targetDir, newSessionID)
103+
104+
return nil
105+
}
106+
107+
type forkResult struct {
108+
lineCount int
109+
}
110+
111+
func forkSession(srcPath, dstPath, newSessionID, targetDir string) (*forkResult, error) {
112+
srcFile, err := os.Open(srcPath)
113+
if err != nil {
114+
return nil, fmt.Errorf("failed to open source session: %w", err)
115+
}
116+
defer srcFile.Close()
117+
118+
dstFile, err := os.Create(dstPath)
119+
if err != nil {
120+
return nil, fmt.Errorf("failed to create target session: %w", err)
121+
}
122+
defer dstFile.Close()
123+
124+
writer := bufio.NewWriter(dstFile)
125+
scanner := bufio.NewScanner(srcFile)
126+
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
127+
128+
lineCount := 0
129+
for scanner.Scan() {
130+
line := scanner.Text()
131+
if strings.TrimSpace(line) == "" {
132+
continue
133+
}
134+
135+
var record map[string]any
136+
if err := json.Unmarshal([]byte(line), &record); err != nil {
137+
writer.WriteString(line)
138+
writer.WriteByte('\n')
139+
lineCount++
140+
continue
141+
}
142+
143+
recordType, _ := record["type"].(string)
144+
145+
if recordType == "file-history-snapshot" {
146+
continue
147+
}
148+
149+
if recordType == "worktree-state" {
150+
record["worktreeSession"] = nil
151+
record["worktreePath"] = nil
152+
}
153+
154+
if _, ok := record["sessionId"]; ok {
155+
record["sessionId"] = newSessionID
156+
}
157+
if _, ok := record["cwd"]; ok {
158+
record["cwd"] = targetDir
159+
}
160+
161+
rewritten, err := json.Marshal(record)
162+
if err != nil {
163+
writer.WriteString(line)
164+
writer.WriteByte('\n')
165+
lineCount++
166+
continue
167+
}
168+
169+
writer.Write(rewritten)
170+
writer.WriteByte('\n')
171+
lineCount++
172+
}
173+
174+
if err := scanner.Err(); err != nil {
175+
return nil, fmt.Errorf("error reading source session: %w", err)
176+
}
177+
if err := writer.Flush(); err != nil {
178+
return nil, fmt.Errorf("error writing target session: %w", err)
179+
}
180+
181+
return &forkResult{lineCount: lineCount}, nil
182+
}
183+
184+
var nonAlphanumeric = regexp.MustCompile(`[^a-zA-Z0-9]`)
185+
186+
// claudeSanitizePath matches Claude Code's sanitizePath exactly:
187+
// replace all non-alphanumeric chars with '-', no stripping.
188+
// /tmp/foo → -tmp-foo (NOT tmp-foo like parser.EncodePath does)
189+
func claudeSanitizePath(path string) string {
190+
return nonAlphanumeric.ReplaceAllString(path, "-")
191+
}

internal/cmd/fork_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestClaudeSanitizePath(t *testing.T) {
13+
tests := []struct {
14+
input string
15+
want string
16+
}{
17+
{"/tmp/foo", "-tmp-foo"},
18+
{"/Users/eric/src/project", "-Users-eric-src-project"},
19+
{"/home/user/my-project", "-home-user-my-project"},
20+
{"/tmp/test fork resume", "-tmp-test-fork-resume"},
21+
{"C:\\Users\\foo", "C--Users-foo"},
22+
}
23+
for _, tt := range tests {
24+
got := claudeSanitizePath(tt.input)
25+
if got != tt.want {
26+
t.Errorf("claudeSanitizePath(%q) = %q, want %q", tt.input, got, tt.want)
27+
}
28+
}
29+
}
30+
31+
func TestClaudeSanitizePathKeepsLeadingDash(t *testing.T) {
32+
got := claudeSanitizePath("/tmp/foo")
33+
if !strings.HasPrefix(got, "-") {
34+
t.Errorf("should keep leading dash, got %q", got)
35+
}
36+
}
37+
38+
func TestForkRewritesSessionIDAndCWD(t *testing.T) {
39+
dir := t.TempDir()
40+
srcPath := filepath.Join(dir, "source.jsonl")
41+
dstPath := filepath.Join(dir, "target.jsonl")
42+
43+
lines := []string{
44+
`{"type":"user","sessionId":"old-id","cwd":"/old/path","uuid":"u1","message":{"role":"user","content":"hi"}}`,
45+
`{"type":"assistant","sessionId":"old-id","cwd":"/old/path","uuid":"a1","parentUuid":"u1","message":{"role":"assistant","content":"hello"}}`,
46+
`{"type":"summary","summary":"test session","leafUuid":"a1"}`,
47+
}
48+
if err := os.WriteFile(srcPath, []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil {
49+
t.Fatal(err)
50+
}
51+
52+
result, err := forkSession(srcPath, dstPath, "new-session-id", "/new/path")
53+
if err != nil {
54+
t.Fatalf("forkSession() error: %v", err)
55+
}
56+
if result.lineCount != 3 {
57+
t.Errorf("lineCount = %d, want 3", result.lineCount)
58+
}
59+
60+
f, _ := os.Open(dstPath)
61+
defer f.Close()
62+
scanner := bufio.NewScanner(f)
63+
for scanner.Scan() {
64+
var record map[string]any
65+
json.Unmarshal([]byte(scanner.Text()), &record)
66+
67+
if sid, ok := record["sessionId"].(string); ok {
68+
if sid != "new-session-id" {
69+
t.Errorf("sessionId = %q, want new-session-id", sid)
70+
}
71+
}
72+
if cwd, ok := record["cwd"].(string); ok {
73+
if cwd != "/new/path" {
74+
t.Errorf("cwd = %q, want /new/path", cwd)
75+
}
76+
}
77+
if record["type"] == "summary" {
78+
if _, ok := record["sessionId"]; ok {
79+
t.Error("summary should not have sessionId added")
80+
}
81+
}
82+
}
83+
}
84+
85+
func TestForkDropsFileHistorySnapshot(t *testing.T) {
86+
dir := t.TempDir()
87+
srcPath := filepath.Join(dir, "source.jsonl")
88+
dstPath := filepath.Join(dir, "target.jsonl")
89+
90+
lines := []string{
91+
`{"type":"file-history-snapshot","messageId":"m1","snapshot":{}}`,
92+
`{"type":"user","sessionId":"old","cwd":"/old","uuid":"u1","message":{"role":"user","content":"hi"}}`,
93+
`{"type":"file-history-snapshot","messageId":"m2","snapshot":{},"isSnapshotUpdate":true}`,
94+
}
95+
os.WriteFile(srcPath, []byte(strings.Join(lines, "\n")+"\n"), 0644)
96+
97+
result, err := forkSession(srcPath, dstPath, "new-id", "/new")
98+
if err != nil {
99+
t.Fatal(err)
100+
}
101+
if result.lineCount != 1 {
102+
t.Errorf("lineCount = %d, want 1 (file-history-snapshot should be dropped)", result.lineCount)
103+
}
104+
105+
content, _ := os.ReadFile(dstPath)
106+
if strings.Contains(string(content), "file-history-snapshot") {
107+
t.Error("forked output should not contain file-history-snapshot records")
108+
}
109+
}
110+
111+
func TestForkNullsWorktreeState(t *testing.T) {
112+
dir := t.TempDir()
113+
srcPath := filepath.Join(dir, "source.jsonl")
114+
dstPath := filepath.Join(dir, "target.jsonl")
115+
116+
lines := []string{
117+
`{"type":"user","sessionId":"old","cwd":"/old","uuid":"u1","message":{"role":"user","content":"hi"}}`,
118+
`{"type":"worktree-state","worktreeSession":{"worktreePath":"/old/worktree"},"worktreePath":"/old/worktree"}`,
119+
}
120+
os.WriteFile(srcPath, []byte(strings.Join(lines, "\n")+"\n"), 0644)
121+
122+
_, err := forkSession(srcPath, dstPath, "new-id", "/new")
123+
if err != nil {
124+
t.Fatal(err)
125+
}
126+
127+
f, _ := os.Open(dstPath)
128+
defer f.Close()
129+
scanner := bufio.NewScanner(f)
130+
for scanner.Scan() {
131+
var record map[string]any
132+
json.Unmarshal([]byte(scanner.Text()), &record)
133+
if record["type"] == "worktree-state" {
134+
if record["worktreeSession"] != nil {
135+
t.Error("worktreeSession should be null in forked output")
136+
}
137+
if record["worktreePath"] != nil {
138+
t.Error("worktreePath should be null in forked output")
139+
}
140+
}
141+
}
142+
}
143+
144+
func TestForkPreservesParentUUIDChain(t *testing.T) {
145+
dir := t.TempDir()
146+
srcPath := filepath.Join(dir, "source.jsonl")
147+
dstPath := filepath.Join(dir, "target.jsonl")
148+
149+
lines := []string{
150+
`{"type":"user","sessionId":"old","cwd":"/old","uuid":"u1","parentUuid":null,"message":{"role":"user","content":"q"}}`,
151+
`{"type":"assistant","sessionId":"old","cwd":"/old","uuid":"a1","parentUuid":"u1","message":{"role":"assistant","content":"a"}}`,
152+
`{"type":"user","sessionId":"old","cwd":"/old","uuid":"u2","parentUuid":"a1","message":{"role":"user","content":"q2"}}`,
153+
}
154+
os.WriteFile(srcPath, []byte(strings.Join(lines, "\n")+"\n"), 0644)
155+
156+
forkSession(srcPath, dstPath, "new-id", "/new")
157+
158+
f, _ := os.Open(dstPath)
159+
defer f.Close()
160+
scanner := bufio.NewScanner(f)
161+
uuids := []string{}
162+
parents := []any{}
163+
for scanner.Scan() {
164+
var record map[string]any
165+
json.Unmarshal([]byte(scanner.Text()), &record)
166+
if u, ok := record["uuid"].(string); ok {
167+
uuids = append(uuids, u)
168+
}
169+
parents = append(parents, record["parentUuid"])
170+
}
171+
172+
if uuids[0] != "u1" || uuids[1] != "a1" || uuids[2] != "u2" {
173+
t.Errorf("message UUIDs should be preserved, got %v", uuids)
174+
}
175+
if parents[1] != "u1" {
176+
t.Errorf("parentUuid chain broken: a1 parent = %v, want u1", parents[1])
177+
}
178+
if parents[2] != "a1" {
179+
t.Errorf("parentUuid chain broken: u2 parent = %v, want a1", parents[2])
180+
}
181+
}

0 commit comments

Comments
 (0)