claude-session-fork 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # claude-session-fork
2
+
3
+ [δΈ­ζ–‡ζ–‡ζ‘£](./README_CN.md)
4
+
5
+ Fork Claude Code sessions at any conversation point and continue in a new terminal.
6
+
7
+ ## Features
8
+
9
+ - πŸ”€ **Fork at any point** - Select any message to create a branch from
10
+ - πŸ“œ **Session browser** - Browse all sessions with preview
11
+ - πŸ“ **Visual history** - Browse conversation with code change indicators (β—†)
12
+ - πŸ–₯️ **Multi-terminal** - Supports Terminal.app, iTerm2, VS Code, Cursor, Kiro
13
+ - ⚑ **Auto-detect** - Automatically uses current session in Claude Code
14
+
15
+ ## Installation
16
+
17
+ ### npm (Recommended)
18
+
19
+ ```bash
20
+ npm install -g claude-session-fork
21
+ ```
22
+
23
+ ### Homebrew (macOS)
24
+
25
+ ```bash
26
+ brew tap duo121/claude-session-fork
27
+ brew install claude-session-fork
28
+ ```
29
+
30
+ ### From source
31
+
32
+ ```bash
33
+ git clone https://github.com/duo121/claude-session-fork.git
34
+ cd claude-session-fork
35
+ npm install && npm run build && npm link
36
+ ```
37
+
38
+ ## Uninstall
39
+
40
+ ### npm
41
+
42
+ ```bash
43
+ npm uninstall -g claude-session-fork
44
+ ```
45
+
46
+ ### Homebrew
47
+
48
+ ```bash
49
+ brew uninstall claude-session-fork
50
+ brew untap duo121/claude-session-fork
51
+ ```
52
+
53
+ ### From source
54
+
55
+ ```bash
56
+ npm unlink -g claude-session-fork
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ```bash
62
+ # Fork current session (auto-detect in Claude Code)
63
+ sfork
64
+
65
+ # Show session list to choose from
66
+ sfork --list
67
+ sfork -l
68
+
69
+ # Fork specific session
70
+ sfork --session=<session-id>
71
+
72
+ # Specify terminal
73
+ sfork --terminal=iterm
74
+ sfork --terminal=vscode
75
+ ```
76
+
77
+ **Commands:** `sfork`, `csfork`, `claude-session-fork`
78
+
79
+ ## Workflow
80
+
81
+ ```
82
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
83
+ β”‚ Session List β”‚ ──► β”‚ Message List β”‚ ──► β”‚ New Terminal β”‚
84
+ β”‚ (--list mode) β”‚ β”‚ (select fork β”‚ β”‚ (forked β”‚
85
+ β”‚ β”‚ β”‚ point) β”‚ β”‚ session) β”‚
86
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
87
+ β”‚ β”‚
88
+ β”‚ Esc β”‚ Esc
89
+ β–Ό β–Ό
90
+ Exit Back to Sessions
91
+ ```
92
+
93
+ ## Controls
94
+
95
+ ### Session List (--list mode)
96
+ | Key | Action |
97
+ |-----|--------|
98
+ | `↑↓` | Navigate sessions |
99
+ | `Enter` | Select session |
100
+ | `Esc` | Exit |
101
+
102
+ ### Message List
103
+ | Key | Action |
104
+ |-----|--------|
105
+ | `↑↓` | Navigate messages |
106
+ | `+/-` | Expand/collapse preview (1-10 lines) |
107
+ | `Space` | Toggle user-only filter |
108
+ | `Enter` | Fork at selected point |
109
+ | `Esc` | Back (list mode) / Exit |
110
+
111
+ ## Command Line Options
112
+
113
+ ```bash
114
+ sfork # Fork current session
115
+ sfork --list, -l # Show session list
116
+ sfork --session=<id> # Fork specific session
117
+ sfork --cwd=<path> # Specify working directory
118
+ sfork --terminal=<type> # Terminal: auto, iterm, terminal, vscode, cursor, kiro
119
+ sfork --help, -h # Show help
120
+ sfork --version, -v # Show version
121
+ ```
122
+
123
+ ## How It Works
124
+
125
+ ```
126
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
127
+ β”‚ Original Session β”‚
128
+ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
129
+ β”‚ [0] You: "Help me build a REST API" β”‚
130
+ β”‚ [1] Claude: "I'll help you create a REST API..." β”‚
131
+ β”‚ [2] You: "Add authentication" ◄── Fork Point β”‚
132
+ β”‚ [3] Claude: "Let's add JWT authentication..." β”‚
133
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
134
+ β”‚
135
+ β–Ό sfork (select message 2)
136
+ β”‚
137
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
138
+ β–Ό β–Ό
139
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
140
+ β”‚ Original Window β”‚ β”‚ New Terminal β”‚
141
+ β”‚ (continues) β”‚ β”‚ (forked from β”‚
142
+ β”‚ β”‚ β”‚ message 2) β”‚
143
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
144
+ ```
145
+
146
+ ## Requirements
147
+
148
+ - macOS (uses AppleScript for terminal control)
149
+ - Claude Code CLI
150
+ - Node.js 18+
151
+
152
+ ## Documentation
153
+
154
+ https://claude-session-fork.vercel.app
155
+
156
+ ## License
157
+
158
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
+ import { useState, useEffect } from 'react';
4
+ import { render, Box, Text, useApp } from 'ink';
5
+ import { findAllSessions, parseSession, forkSession, launchClaudeSession, openTerminalWithUI, getProjectDir } from './session.js';
6
+ import { SessionList, MessageList } from './components.js';
7
+ // Parse CLI arguments
8
+ const args = process.argv.slice(2);
9
+ const isInteractive = args.includes('--interactive');
10
+ const sessionArg = args.find(a => a.startsWith('--session='));
11
+ const cwdArg = args.find(a => a.startsWith('--cwd='));
12
+ const terminalArg = args.find(a => a.startsWith('--terminal='));
13
+ const showHelp = args.includes('--help') || args.includes('-h');
14
+ const showVersion = args.includes('--version') || args.includes('-v');
15
+ const showList = args.includes('--list') || args.includes('-l');
16
+ const cwd = cwdArg ? cwdArg.split('=')[1] : process.cwd();
17
+ const sessionId = sessionArg ? sessionArg.split('=')[1] : null;
18
+ const terminalType = terminalArg ? terminalArg.split('=')[1] : process.env.TERM_PROGRAM;
19
+ // Show help
20
+ if (showHelp) {
21
+ console.log(`
22
+ claude-session-fork (csfork/sfork) - Fork Claude Code sessions
23
+
24
+ Usage:
25
+ csfork Fork current session (latest modified)
26
+ csfork --list Open session list to choose
27
+ csfork --session=<id> Fork specific session
28
+
29
+ Options:
30
+ --session=<id> Specify session ID
31
+ --list, -l Show session list to choose from
32
+ --cwd=<path> Working directory (default: current)
33
+ --terminal=<type> Terminal type: auto, iterm, terminal, vscode, cursor, kiro
34
+ -h, --help Show this help
35
+ -v, --version Show version
36
+
37
+ Controls:
38
+ ↑↓ Navigate
39
+ Enter Select / Fork
40
+ +/- Expand/collapse message preview
41
+ Space Toggle user-only filter
42
+ Esc Back / Exit
43
+
44
+ Docs: https://claude-session-fork.vercel.app
45
+ `);
46
+ process.exit(0);
47
+ }
48
+ // Show version
49
+ if (showVersion) {
50
+ console.log('1.0.0');
51
+ process.exit(0);
52
+ }
53
+ // Non-interactive mode: open UI in new terminal
54
+ if (!isInteractive) {
55
+ const sessions = findAllSessions(cwd);
56
+ if (sessions.length === 0) {
57
+ console.error('No sessions found for:', cwd);
58
+ process.exit(1);
59
+ }
60
+ console.log(`Found ${sessions.length} session(s)`);
61
+ // --session=<id>: use specific session
62
+ // --list or -l: show session list
63
+ // default: use latest session (current session in Claude Code)
64
+ let targetSession;
65
+ if (sessionId) {
66
+ targetSession = sessionId;
67
+ console.log(`Using session: ${sessionId}`);
68
+ }
69
+ else if (showList) {
70
+ targetSession = '__list__';
71
+ console.log(`Opening session list...`);
72
+ }
73
+ else {
74
+ // Latest session = current session (most recently modified)
75
+ targetSession = sessions[0].id;
76
+ console.log(`Using current session: ${targetSession.slice(0, 8)}...`);
77
+ }
78
+ console.log(`Opening fork UI...`);
79
+ openTerminalWithUI(cwd, targetSession, terminalType);
80
+ process.exit(0);
81
+ }
82
+ function App() {
83
+ const { exit } = useApp();
84
+ const [step, setStep] = useState(sessionId && sessionId !== '__list__' ? 'messages' : 'sessions');
85
+ const [sessions, setSessions] = useState([]);
86
+ const [selectedSession, setSelectedSession] = useState(null);
87
+ const [messages, setMessages] = useState([]);
88
+ const [error, setError] = useState('');
89
+ useEffect(() => {
90
+ // Clear screen
91
+ process.stdout.write('\x1Bc');
92
+ const allSessions = findAllSessions(cwd);
93
+ setSessions(allSessions);
94
+ // If session ID provided, go directly to messages
95
+ if (sessionId && sessionId !== '__list__') {
96
+ const projectDir = getProjectDir(cwd);
97
+ const file = `${projectDir}/${sessionId}.jsonl`;
98
+ try {
99
+ const msgs = parseSession(file);
100
+ if (msgs.length === 0) {
101
+ setError('No messages found in session');
102
+ setStep('error');
103
+ return;
104
+ }
105
+ setSelectedSession({ id: sessionId, file, mtime: new Date() });
106
+ setMessages(msgs);
107
+ setStep('messages');
108
+ }
109
+ catch (e) {
110
+ setError(`Failed to parse session: ${e}`);
111
+ setStep('error');
112
+ }
113
+ }
114
+ else if (allSessions.length === 0) {
115
+ setError('No sessions found for this directory');
116
+ setStep('error');
117
+ }
118
+ }, []);
119
+ const handleSessionSelect = (session) => {
120
+ try {
121
+ const msgs = parseSession(session.file);
122
+ if (msgs.length === 0) {
123
+ setError('No messages found in session');
124
+ setStep('error');
125
+ return;
126
+ }
127
+ setSelectedSession(session);
128
+ setMessages(msgs);
129
+ setStep('messages');
130
+ }
131
+ catch (e) {
132
+ setError(`Failed to parse session: ${e}`);
133
+ setStep('error');
134
+ }
135
+ };
136
+ const handleMessageSelect = (uuid) => {
137
+ if (!selectedSession)
138
+ return;
139
+ setStep('forking');
140
+ setTimeout(() => {
141
+ try {
142
+ process.stdout.write('\x1Bc');
143
+ const newId = forkSession(selectedSession.file, uuid);
144
+ launchClaudeSession(newId, cwd, terminalType);
145
+ setStep('done');
146
+ setTimeout(() => {
147
+ exit();
148
+ process.exit(0);
149
+ }, 100);
150
+ }
151
+ catch (e) {
152
+ setError(`Failed to fork: ${e}`);
153
+ setStep('error');
154
+ }
155
+ }, 100);
156
+ };
157
+ const handleBack = () => {
158
+ // Only allow back if we started from session list
159
+ if (!sessionId || sessionId === '__list__') {
160
+ setStep('sessions');
161
+ setSelectedSession(null);
162
+ setMessages([]);
163
+ }
164
+ };
165
+ const handleExit = () => {
166
+ process.stdout.write('\x1Bc');
167
+ exit();
168
+ process.exit(0);
169
+ };
170
+ if (step === 'error') {
171
+ return (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["Error: ", error] }), _jsx(Text, { dimColor: true, children: "Press Esc to exit" })] }));
172
+ }
173
+ if (step === 'forking') {
174
+ return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "yellow", children: "Creating fork..." }) }));
175
+ }
176
+ if (step === 'done') {
177
+ return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "green", children: "\u2713 Fork created, launching Claude..." }) }));
178
+ }
179
+ if (step === 'sessions') {
180
+ return (_jsx(SessionList, { sessions: sessions, onSelect: handleSessionSelect, onExit: handleExit }));
181
+ }
182
+ if (step === 'messages') {
183
+ // Allow back only if we came from session list
184
+ const canGoBack = !sessionId || sessionId === '__list__';
185
+ return (_jsx(MessageList, { messages: messages, onSelect: handleMessageSelect, onBack: canGoBack ? handleBack : undefined, onExit: handleExit }));
186
+ }
187
+ return null;
188
+ }
189
+ render(_jsx(App, {}));
@@ -0,0 +1,15 @@
1
+ import type { Message, Session } from './session.js';
2
+ interface SessionListProps {
3
+ sessions: Session[];
4
+ onSelect: (session: Session) => void;
5
+ onExit: () => void;
6
+ }
7
+ export declare function SessionList({ sessions, onSelect, onExit }: SessionListProps): import("react/jsx-runtime").JSX.Element;
8
+ interface MessageListProps {
9
+ messages: Message[];
10
+ onSelect: (uuid: string) => void;
11
+ onBack?: () => void;
12
+ onExit: () => void;
13
+ }
14
+ export declare function MessageList({ messages: allMessages, onSelect, onBack, onExit }: MessageListProps): import("react/jsx-runtime").JSX.Element;
15
+ export {};
@@ -0,0 +1,160 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ export function SessionList({ sessions, onSelect, onExit }) {
5
+ const { stdout } = useStdout();
6
+ const [selectedIndex, setSelectedIndex] = useState(0);
7
+ const terminalHeight = stdout?.rows || 24;
8
+ const maxVisible = Math.max(3, terminalHeight - 8);
9
+ useInput((input, key) => {
10
+ if (key.escape) {
11
+ onExit();
12
+ return;
13
+ }
14
+ if (key.upArrow) {
15
+ setSelectedIndex(i => Math.max(0, i - 1));
16
+ }
17
+ if (key.downArrow) {
18
+ setSelectedIndex(i => Math.min(sessions.length - 1, i + 1));
19
+ }
20
+ if (key.return && sessions.length > 0) {
21
+ onSelect(sessions[selectedIndex]);
22
+ }
23
+ });
24
+ if (sessions.length === 0) {
25
+ return (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "red", children: "No sessions found for this directory" }), _jsx(Text, { dimColor: true, children: "Press Esc to exit" })] }));
26
+ }
27
+ // Calculate visible window
28
+ let startIdx = 0;
29
+ let endIdx = sessions.length;
30
+ if (sessions.length > maxVisible) {
31
+ const halfWindow = Math.floor(maxVisible / 2);
32
+ startIdx = Math.max(0, selectedIndex - halfWindow);
33
+ endIdx = Math.min(sessions.length, startIdx + maxVisible);
34
+ if (endIdx === sessions.length) {
35
+ startIdx = Math.max(0, sessions.length - maxVisible);
36
+ }
37
+ }
38
+ const visibleSessions = sessions.slice(startIdx, endIdx);
39
+ const formatTime = (date) => {
40
+ const now = new Date();
41
+ const diff = now.getTime() - date.getTime();
42
+ const minutes = Math.floor(diff / 60000);
43
+ const hours = Math.floor(diff / 3600000);
44
+ const days = Math.floor(diff / 86400000);
45
+ if (minutes < 1)
46
+ return 'just now';
47
+ if (minutes < 60)
48
+ return `${minutes}m ago`;
49
+ if (hours < 24)
50
+ return `${hours}h ago`;
51
+ if (days < 7)
52
+ return `${days}d ago`;
53
+ return date.toLocaleDateString();
54
+ };
55
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { bold: true, color: "blue", children: "Sessions" }), _jsxs(Text, { dimColor: true, children: [" (", selectedIndex + 1, "/", sessions.length, ")"] })] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select a session to fork from" }) }), startIdx > 0 && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [" \u2191 ", startIdx, " more above"] }) })), visibleSessions.map((session, visibleIdx) => {
56
+ const actualIndex = startIdx + visibleIdx;
57
+ const isSelected = actualIndex === selectedIndex;
58
+ const isCurrent = actualIndex === 0;
59
+ const preview = session.firstMessage || `Session ${session.id.slice(0, 8)}...`;
60
+ const time = formatTime(session.mtime);
61
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, children: isSelected ? '❯ ' : ' ' }), _jsxs(Text, { color: isSelected ? 'white' : 'gray', bold: isSelected, children: [preview.slice(0, 60), preview.length > 60 ? '...' : ''] }), isCurrent && _jsx(Text, { color: "yellow", italic: true, children: " (latest)" })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [" ", time, " \u00B7 ", session.id.slice(0, 8)] }) })] }, session.id));
62
+ }), endIdx < sessions.length && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [" \u2193 ", sessions.length - endIdx, " more below"] }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 Move \u00B7 Enter Select \u00B7 Esc Exit" }) })] }));
63
+ }
64
+ export function MessageList({ messages: allMessages, onSelect, onBack, onExit }) {
65
+ const { stdout } = useStdout();
66
+ const [selectedIndex, setSelectedIndex] = useState(0);
67
+ const [expandLines, setExpandLines] = useState(1);
68
+ const [showOnlyUser, setShowOnlyUser] = useState(false);
69
+ // Filter messages based on mode
70
+ const messages = showOnlyUser
71
+ ? allMessages.filter(m => m.type === 'user')
72
+ : allMessages;
73
+ // Reset selection when filter changes
74
+ useEffect(() => {
75
+ setSelectedIndex(Math.min(selectedIndex, Math.max(0, messages.length - 1)));
76
+ }, [showOnlyUser, messages.length]);
77
+ // Calculate layout
78
+ const terminalHeight = stdout?.rows || 24;
79
+ const layoutLines = 5;
80
+ const reservedForExpand = 5;
81
+ const availableLines = terminalHeight - layoutLines - reservedForExpand;
82
+ const maxVisible = Math.max(3, availableLines);
83
+ useInput((input, key) => {
84
+ if (key.escape) {
85
+ if (onBack) {
86
+ onBack();
87
+ }
88
+ else {
89
+ onExit();
90
+ }
91
+ return;
92
+ }
93
+ // Space: toggle user-only filter
94
+ if (input === ' ') {
95
+ setShowOnlyUser(v => !v);
96
+ return;
97
+ }
98
+ // +/= : increase expand lines
99
+ if (input === '=' || input === '+') {
100
+ setExpandLines(n => Math.min(n + 1, 10));
101
+ return;
102
+ }
103
+ // - : decrease expand lines
104
+ if (input === '-') {
105
+ setExpandLines(n => Math.max(n - 1, 1));
106
+ return;
107
+ }
108
+ if (key.upArrow) {
109
+ setSelectedIndex(i => Math.max(0, i - 1));
110
+ }
111
+ if (key.downArrow) {
112
+ setSelectedIndex(i => Math.min(messages.length - 1, i + 1));
113
+ }
114
+ if (key.return && messages.length > 0) {
115
+ onSelect(messages[selectedIndex].uuid);
116
+ }
117
+ });
118
+ if (messages.length === 0) {
119
+ return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "red", children: "No messages to display" }) }));
120
+ }
121
+ // Calculate visible window
122
+ const totalMessages = messages.length;
123
+ let startIdx = 0;
124
+ let endIdx = totalMessages;
125
+ if (totalMessages > maxVisible) {
126
+ const halfWindow = Math.floor(maxVisible / 2);
127
+ startIdx = Math.max(0, selectedIndex - halfWindow);
128
+ endIdx = Math.min(totalMessages, startIdx + maxVisible);
129
+ if (endIdx === totalMessages) {
130
+ startIdx = Math.max(0, totalMessages - maxVisible);
131
+ }
132
+ }
133
+ const visibleMessages = messages.slice(startIdx, endIdx);
134
+ // Helper to get content lines
135
+ const getContentLines = (content, maxLen, numLines) => {
136
+ const lines = [];
137
+ let remaining = content;
138
+ for (let i = 0; i < numLines && remaining.length > 0; i++) {
139
+ lines.push(remaining.slice(0, maxLen));
140
+ remaining = remaining.slice(maxLen);
141
+ }
142
+ if (remaining.length > 0 && lines.length > 0) {
143
+ lines[lines.length - 1] = lines[lines.length - 1].slice(0, -3) + '...';
144
+ }
145
+ return lines;
146
+ };
147
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { bold: true, color: "blue", children: "Fork" }), _jsxs(Text, { dimColor: true, children: [" (", selectedIndex + 1, "/", totalMessages, ")"] }), _jsxs(Text, { color: "cyan", children: [" [", expandLines, "\u884C]"] }), showOnlyUser && _jsx(Text, { color: "yellow", children: " [User Only]" })] }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Select the point to fork from", onBack ? ' Β· Esc to go back' : ''] }) }), startIdx > 0 && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [" \u2191 ", startIdx, " more above"] }) })), visibleMessages.flatMap((msg, visibleIdx) => {
148
+ const actualIndex = startIdx + visibleIdx;
149
+ const isSelected = actualIndex === selectedIndex;
150
+ const isCurrent = actualIndex === messages.length - 1;
151
+ const isUser = msg.type === 'user';
152
+ const maxLen = 65;
153
+ const numLines = isSelected ? expandLines : 1;
154
+ const contentLines = getContentLines(msg.content, maxLen, numLines);
155
+ return contentLines.map((line, lineIdx) => {
156
+ const isFirstLine = lineIdx === 0;
157
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, children: isFirstLine ? (isSelected ? '❯ ' : ' ') : ' ' }), isFirstLine ? (_jsx(Text, { color: isUser ? 'green' : 'magenta', bold: true, inverse: isSelected, children: isUser ? ' You ' : ' AI ' })) : (_jsx(Text, { children: " " })), _jsx(Text, { children: " " }), isFirstLine ? (_jsx(Text, { color: msg.hasCodeChanges ? 'yellow' : 'gray', children: msg.hasCodeChanges ? 'β—† ' : ' ' })) : (_jsx(Text, { children: " " })), _jsx(Text, { color: isSelected ? 'white' : 'gray', bold: isSelected && isFirstLine, children: line }), isFirstLine && isCurrent && (_jsx(Text, { color: "yellow", italic: true, children: " (current)" }))] }, `${actualIndex}-${lineIdx}`));
158
+ });
159
+ }), endIdx < totalMessages && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [" \u2193 ", totalMessages - endIdx, " more below"] }) })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 Move \u00B7 +/- Lines \u00B7 Space Filter \u00B7 Enter Fork \u00B7 Esc ", onBack ? 'Back' : 'Exit'] }) })] }));
160
+ }
@@ -0,0 +1,19 @@
1
+ export interface Message {
2
+ uuid: string;
3
+ type: 'user' | 'assistant';
4
+ content: string;
5
+ hasCodeChanges: boolean;
6
+ }
7
+ export interface Session {
8
+ id: string;
9
+ file: string;
10
+ mtime: Date;
11
+ firstMessage?: string;
12
+ }
13
+ export declare function getProjectDir(dir?: string): string;
14
+ export declare function findLatestSession(dir?: string): Session | null;
15
+ export declare function findAllSessions(dir?: string): Session[];
16
+ export declare function parseSession(sessionFile: string): Message[];
17
+ export declare function forkSession(sessionFile: string, forkUuid: string): string;
18
+ export declare function launchClaudeSession(sessionId: string, cwd: string, terminal?: string): void;
19
+ export declare function openTerminalWithUI(cwd: string, sessionId: string, terminal?: string): void;
@@ -0,0 +1,238 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { spawn, execSync } from 'child_process';
5
+ // Patterns to filter out system messages
6
+ const filterPatterns = [
7
+ /^Caveat:/,
8
+ /^<bash-input>/,
9
+ /^<bash-stdout>/,
10
+ /^<bash-stderr>/,
11
+ /^<system-reminder>/,
12
+ /^\[Request interrupted/,
13
+ /^ERROR\s/,
14
+ /^!\s*sfork/,
15
+ /^Last login:/,
16
+ ];
17
+ function getProjectHash(dir) {
18
+ return dir.replace(/^\//g, '-').replace(/\//g, '-');
19
+ }
20
+ export function getProjectDir(dir = process.cwd()) {
21
+ const projectHash = getProjectHash(dir);
22
+ return path.join(os.homedir(), '.claude', 'projects', projectHash);
23
+ }
24
+ export function findLatestSession(dir = process.cwd()) {
25
+ const sessions = findAllSessions(dir);
26
+ return sessions[0] || null;
27
+ }
28
+ export function findAllSessions(dir = process.cwd()) {
29
+ const projectDir = getProjectDir(dir);
30
+ if (!fs.existsSync(projectDir)) {
31
+ return [];
32
+ }
33
+ return fs.readdirSync(projectDir)
34
+ .filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
35
+ .map(f => {
36
+ const filePath = path.join(projectDir, f);
37
+ const id = f.replace('.jsonl', '');
38
+ const stat = fs.statSync(filePath);
39
+ // Get first user message as preview
40
+ let firstMessage = '';
41
+ try {
42
+ const content = fs.readFileSync(filePath, 'utf-8');
43
+ for (const line of content.split('\n')) {
44
+ if (!line)
45
+ continue;
46
+ try {
47
+ const data = JSON.parse(line);
48
+ if (data.type === 'user' && data.message) {
49
+ const msg = typeof data.message.content === 'string'
50
+ ? data.message.content
51
+ : data.message.content?.filter?.((c) => c.type === 'text')?.map?.((c) => c.text)?.join(' ') || '';
52
+ const cleaned = msg.replace(/^[\s\n\r]+/, '').replace(/\s+/g, ' ').trim();
53
+ if (cleaned && cleaned !== '[Request interrupted by user]') {
54
+ firstMessage = cleaned.slice(0, 60);
55
+ break;
56
+ }
57
+ }
58
+ }
59
+ catch { }
60
+ }
61
+ }
62
+ catch { }
63
+ return {
64
+ id,
65
+ file: filePath,
66
+ mtime: stat.mtime,
67
+ firstMessage
68
+ };
69
+ })
70
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
71
+ }
72
+ export function parseSession(sessionFile) {
73
+ const messagesMap = new Map();
74
+ const codeChangesByUuid = new Set();
75
+ const content = fs.readFileSync(sessionFile, 'utf-8');
76
+ for (const line of content.split('\n')) {
77
+ if (!line)
78
+ continue;
79
+ try {
80
+ const data = JSON.parse(line);
81
+ // Track tool uses for code changes
82
+ if (data.type === 'assistant' && data.message?.content) {
83
+ const hasToolUse = Array.isArray(data.message.content) &&
84
+ data.message.content.some((c) => c.type === 'tool_use' &&
85
+ ['Edit', 'Write', 'Bash', 'MultiEdit'].includes(c.name));
86
+ if (hasToolUse && data.uuid) {
87
+ codeChangesByUuid.add(data.uuid);
88
+ }
89
+ }
90
+ if ((data.type === 'user' || data.type === 'assistant') && data.uuid) {
91
+ let msg = '';
92
+ if (data.message) {
93
+ msg = typeof data.message.content === 'string'
94
+ ? data.message.content
95
+ : data.message.content?.filter?.((c) => c.type === 'text')?.map?.((c) => c.text)?.join(' ') || '';
96
+ }
97
+ // Clean up message
98
+ msg = msg.replace(/^[\s\n\r]+/, '').replace(/\s+/g, ' ').trim();
99
+ // Filter out system messages
100
+ const shouldFilter = filterPatterns.some(pattern => pattern.test(msg));
101
+ if (msg && !shouldFilter && msg !== '[Request interrupted by user]' && msg !== 'No response requested.') {
102
+ if (!messagesMap.has(data.uuid)) {
103
+ messagesMap.set(data.uuid, {
104
+ uuid: data.uuid,
105
+ type: data.type,
106
+ content: msg,
107
+ hasCodeChanges: false
108
+ });
109
+ }
110
+ }
111
+ }
112
+ }
113
+ catch { }
114
+ }
115
+ // Mark code changes
116
+ for (const uuid of codeChangesByUuid) {
117
+ const msg = messagesMap.get(uuid);
118
+ if (msg) {
119
+ msg.hasCodeChanges = true;
120
+ }
121
+ }
122
+ return Array.from(messagesMap.values());
123
+ }
124
+ export function forkSession(sessionFile, forkUuid) {
125
+ const projectDir = path.dirname(sessionFile);
126
+ const newId = crypto.randomUUID();
127
+ const newFile = path.join(projectDir, `${newId}.jsonl`);
128
+ const content = fs.readFileSync(sessionFile, 'utf-8');
129
+ const lines = content.split('\n').filter(Boolean);
130
+ const newLines = [];
131
+ for (const line of lines) {
132
+ try {
133
+ const data = JSON.parse(line);
134
+ if (data.sessionId)
135
+ data.sessionId = newId;
136
+ newLines.push(JSON.stringify(data));
137
+ if (data.uuid === forkUuid)
138
+ break;
139
+ }
140
+ catch {
141
+ newLines.push(line);
142
+ }
143
+ }
144
+ fs.writeFileSync(newFile, newLines.join('\n') + '\n');
145
+ return newId;
146
+ }
147
+ export function launchClaudeSession(sessionId, cwd, terminal) {
148
+ const cmd = `cd '${cwd}' && claude --resume '${sessionId}'`;
149
+ const terminalLower = terminal?.toLowerCase() || '';
150
+ // VS Code / Cursor / Kiro - use code CLI to open terminal
151
+ if (terminalLower.includes('vscode') || terminalLower === 'code') {
152
+ execSync(`code --folder-uri "file://${cwd}" -r`);
153
+ // VS Code doesn't have a direct way to run command in terminal via CLI
154
+ // Copy command to clipboard and notify user
155
+ execSync(`echo "${cmd}" | pbcopy`);
156
+ console.log('Command copied to clipboard. Paste in VS Code terminal.');
157
+ return;
158
+ }
159
+ if (terminalLower.includes('cursor')) {
160
+ execSync(`cursor --folder-uri "file://${cwd}" -r`);
161
+ execSync(`echo "${cmd}" | pbcopy`);
162
+ console.log('Command copied to clipboard. Paste in Cursor terminal.');
163
+ return;
164
+ }
165
+ if (terminalLower.includes('kiro')) {
166
+ execSync(`kiro "${cwd}"`);
167
+ execSync(`echo "${cmd}" | pbcopy`);
168
+ console.log('Command copied to clipboard. Paste in Kiro terminal.');
169
+ return;
170
+ }
171
+ // iTerm2
172
+ const isIterm = terminalLower === 'iterm' || terminalLower === 'iterm.app' ||
173
+ (terminalLower !== 'terminal' && terminalLower !== 'terminal.app' && fs.existsSync('/Applications/iTerm.app'));
174
+ if (isIterm) {
175
+ const script = `
176
+ tell application "iTerm"
177
+ activate
178
+ create window with default profile
179
+ tell current session of current window
180
+ write text "${cmd}"
181
+ end tell
182
+ end tell
183
+ `;
184
+ execSync(`osascript -e '${script}'`);
185
+ }
186
+ else {
187
+ // Terminal.app
188
+ const script = `
189
+ tell application "Terminal"
190
+ activate
191
+ do script "${cmd}"
192
+ end tell
193
+ `;
194
+ execSync(`osascript -e '${script}'`);
195
+ }
196
+ }
197
+ export function openTerminalWithUI(cwd, sessionId, terminal) {
198
+ const sforkPath = process.argv[1];
199
+ const cmd = `node '${sforkPath}' --interactive --session='${sessionId}' --cwd='${cwd}'`;
200
+ const fullCmd = `cd '${cwd}' && ${cmd}`;
201
+ const terminalLower = terminal?.toLowerCase() || '';
202
+ // VS Code / Cursor / Kiro - copy command to clipboard
203
+ if (terminalLower.includes('vscode') || terminalLower === 'code' ||
204
+ terminalLower.includes('cursor') || terminalLower.includes('kiro')) {
205
+ execSync(`echo "${fullCmd}" | pbcopy`);
206
+ console.log('Command copied to clipboard. Open a terminal and paste to run.');
207
+ return;
208
+ }
209
+ // iTerm2
210
+ const isIterm = terminalLower === 'iterm' || terminalLower === 'iterm.app' ||
211
+ (terminalLower !== 'terminal' && terminalLower !== 'terminal.app' && fs.existsSync('/Applications/iTerm.app'));
212
+ if (isIterm) {
213
+ const script = `
214
+ tell application "iTerm"
215
+ activate
216
+ tell current window
217
+ create tab with default profile
218
+ tell current session
219
+ write text "${fullCmd}"
220
+ end tell
221
+ end tell
222
+ end tell
223
+ `;
224
+ spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
225
+ }
226
+ else {
227
+ // Terminal.app
228
+ const script = `
229
+ tell application "Terminal"
230
+ activate
231
+ tell application "System Events" to keystroke "t" using command down
232
+ delay 0.3
233
+ do script "${fullCmd}" in front window
234
+ end tell
235
+ `;
236
+ spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
237
+ }
238
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "claude-session-fork",
3
+ "version": "1.0.0",
4
+ "description": "Fork Claude Code sessions at any conversation point",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-session-fork": "./dist/cli.js",
8
+ "csfork": "./dist/cli.js",
9
+ "sfork": "./dist/cli.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node dist/cli.js",
13
+ "build": "tsc",
14
+ "dev": "tsc --watch",
15
+ "prepublishOnly": "npm run build",
16
+ "postinstall": "node scripts/postinstall.js || true"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "scripts/postinstall.js"
21
+ ],
22
+ "dependencies": {
23
+ "ink": "^5.0.1",
24
+ "react": "^18.3.1"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.10.10",
28
+ "typescript": "^5.7.3"
29
+ },
30
+ "keywords": [
31
+ "claude",
32
+ "claude-code",
33
+ "fork",
34
+ "session",
35
+ "cli",
36
+ "ai",
37
+ "conversation"
38
+ ],
39
+ "author": "duo121",
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/duo121/claude-session-fork.git"
44
+ },
45
+ "homepage": "https://claude-session-fork.vercel.app",
46
+ "bugs": {
47
+ "url": "https://github.com/duo121/claude-session-fork/issues"
48
+ },
49
+ "engines": {
50
+ "node": ">=18.0.0"
51
+ }
52
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Post-install message
4
+ console.log(`
5
+ ╔═══════════════════════════════════════════════════════════╗
6
+ β•‘ β•‘
7
+ β•‘ claude-session-fork installed! πŸŽ‰ β•‘
8
+ β•‘ β•‘
9
+ β•‘ Usage: β•‘
10
+ β•‘ cd your-project β•‘
11
+ β•‘ csfork β•‘
12
+ β•‘ β•‘
13
+ β•‘ Commands: csfork, sfork, claude-session-fork β•‘
14
+ β•‘ Docs: https://claude-session-fork.vercel.app β•‘
15
+ β•‘ β•‘
16
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
17
+ `);