@claudemini/shit-cli 1.0.3 → 1.1.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.
@@ -13,7 +13,8 @@
13
13
  "Bash(git add:*)",
14
14
  "Bash(git commit -m \"$\\(cat <<''EOF''\n更新包名和版本号\n\n- 包名改为 @cluademini/shit-cli\n- 版本号升至 1.0.3\nEOF\n\\)\")",
15
15
  "Bash(git commit:*)",
16
- "Bash(npm config set:*)"
16
+ "Bash(npm config set:*)",
17
+ "WebFetch(domain:github.com)"
17
18
  ]
18
19
  }
19
20
  }
package/bin/shit.js CHANGED
@@ -4,14 +4,20 @@ const args = process.argv.slice(2);
4
4
  const command = args[0];
5
5
 
6
6
  const commands = {
7
- init: 'Initialize hooks in .claude/settings.json',
8
- log: 'Log a hook event (called by hooks)',
9
- list: 'List all sessions',
10
- view: 'View session details',
11
- query: 'Query session memory (cross-session)',
12
- shadow: 'List shadow branches',
13
- clean: 'Clean old sessions',
14
- help: 'Show help',
7
+ enable: 'Enable shit-cli in repository',
8
+ disable: 'Remove shit-cli hooks',
9
+ status: 'Show current session status',
10
+ init: 'Initialize hooks in .claude/settings.json',
11
+ log: 'Log a hook event (called by hooks)',
12
+ list: 'List all sessions',
13
+ view: 'View session details',
14
+ query: 'Query session memory (cross-session)',
15
+ rewind: 'Rollback to previous checkpoint',
16
+ resume: 'Resume session from checkpoint',
17
+ doctor: 'Fix or clean stuck sessions',
18
+ shadow: 'List shadow branches',
19
+ clean: 'Clean old sessions',
20
+ help: 'Show help',
15
21
  };
16
22
 
17
23
  function showHelp() {
@@ -22,16 +28,17 @@ function showHelp() {
22
28
  console.log(` ${cmd.padEnd(10)} ${desc}`);
23
29
  });
24
30
  console.log('\nExamples:');
25
- console.log(' shit init # Register hooks');
31
+ console.log(' shit enable # Enable shit-cli in repo');
32
+ console.log(' shit status # Show current session');
26
33
  console.log(' shit list # List sessions');
27
34
  console.log(' shit view <session-id> # View session');
28
- console.log(' shit view <session-id> --json # View with JSON data');
35
+ console.log(' shit rewind <checkpoint> # Rollback to checkpoint');
36
+ console.log(' shit resume <checkpoint> # Resume from checkpoint');
37
+ console.log(' shit doctor --fix # Fix stuck sessions');
29
38
  console.log(' shit query --recent=5 # Recent 5 sessions');
30
39
  console.log(' shit query --file=src/app.ts # Sessions that touched file');
31
- console.log(' shit query --type=bugfix # Filter by session type');
32
- console.log(' shit query --risk=high --json # High-risk sessions as JSON');
33
40
  console.log(' shit shadow # List shadow branches');
34
- console.log(' shit clean --days=7 # Clean old sessions');
41
+ console.log(' shit disable --clean # Remove shit-cli and data');
35
42
  }
36
43
 
37
44
  if (!command || command === 'help' || command === '--help') {
package/lib/disable.js ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Disable shit-cli in repository
5
+ * Similar to 'entire disable' - removes hooks and optionally cleans data
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, rmSync } from 'fs';
9
+ import { join } from 'path';
10
+
11
+ function findProjectRoot() {
12
+ let dir = process.cwd();
13
+ while (dir !== '/') {
14
+ if (existsSync(join(dir, '.git'))) {
15
+ return dir;
16
+ }
17
+ dir = join(dir, '..');
18
+ }
19
+ throw new Error('Not in a git repository');
20
+ }
21
+
22
+ function removeClaudeHooks(projectRoot) {
23
+ const settingsFile = join(projectRoot, '.claude', 'settings.json');
24
+
25
+ if (!existsSync(settingsFile)) {
26
+ return false;
27
+ }
28
+
29
+ try {
30
+ const settings = JSON.parse(readFileSync(settingsFile, 'utf-8'));
31
+
32
+ if (settings.hooks) {
33
+ // Remove shit-cli hooks
34
+ delete settings.hooks.session_start;
35
+ delete settings.hooks.session_end;
36
+ delete settings.hooks.tool_use;
37
+ delete settings.hooks.edit_applied;
38
+
39
+ // If hooks object is empty, remove it
40
+ if (Object.keys(settings.hooks).length === 0) {
41
+ delete settings.hooks;
42
+ }
43
+ }
44
+
45
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ function removeFromGitignore(projectRoot) {
53
+ const gitignoreFile = join(projectRoot, '.gitignore');
54
+
55
+ if (!existsSync(gitignoreFile)) {
56
+ return false;
57
+ }
58
+
59
+ try {
60
+ let gitignore = readFileSync(gitignoreFile, 'utf-8');
61
+ const lines = gitignore.split('\n');
62
+ const filtered = lines.filter(line => !line.trim().startsWith('.shit-logs'));
63
+
64
+ if (filtered.length !== lines.length) {
65
+ writeFileSync(gitignoreFile, filtered.join('\n'));
66
+ return true;
67
+ }
68
+ } catch {
69
+ // Ignore errors
70
+ }
71
+
72
+ return false;
73
+ }
74
+
75
+ export default async function disable(args) {
76
+ try {
77
+ const projectRoot = findProjectRoot();
78
+ const cleanData = args.includes('--clean') || args.includes('--purge');
79
+
80
+ console.log('🔧 Disabling shit-cli in repository...');
81
+
82
+ // Remove Claude Code hooks
83
+ const hooksRemoved = removeClaudeHooks(projectRoot);
84
+ if (hooksRemoved) {
85
+ console.log('✅ Removed Claude hooks from .claude/settings.json');
86
+ } else {
87
+ console.log('ℹ️ No Claude hooks found to remove');
88
+ }
89
+
90
+ // Remove from .gitignore
91
+ const gitignoreUpdated = removeFromGitignore(projectRoot);
92
+ if (gitignoreUpdated) {
93
+ console.log('✅ Removed .shit-logs from .gitignore');
94
+ }
95
+
96
+ // Optionally clean data
97
+ if (cleanData) {
98
+ const shitLogsDir = join(projectRoot, '.shit-logs');
99
+ if (existsSync(shitLogsDir)) {
100
+ rmSync(shitLogsDir, { recursive: true, force: true });
101
+ console.log('✅ Removed .shit-logs directory');
102
+ }
103
+ }
104
+
105
+ console.log('\n🎉 shit-cli disabled successfully!');
106
+
107
+ if (!cleanData) {
108
+ console.log('\nNote: .shit-logs directory preserved.');
109
+ console.log('Use "shit disable --clean" to remove all data.');
110
+ }
111
+
112
+ } catch (error) {
113
+ console.error('❌ Failed to disable shit-cli:', error.message);
114
+ process.exit(1);
115
+ }
116
+ }
package/lib/doctor.js ADDED
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Doctor command - fix or clean stuck sessions
5
+ * Similar to 'entire doctor' - repairs corrupted state and cleans up
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { execSync } from 'child_process';
11
+
12
+ function findProjectRoot() {
13
+ let dir = process.cwd();
14
+ while (dir !== '/') {
15
+ if (existsSync(join(dir, '.git'))) {
16
+ return dir;
17
+ }
18
+ dir = join(dir, '..');
19
+ }
20
+ throw new Error('Not in a git repository');
21
+ }
22
+
23
+ function git(cmd, cwd) {
24
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
25
+ }
26
+
27
+ function checkShitLogsStructure(projectRoot) {
28
+ const issues = [];
29
+ const shitLogsDir = join(projectRoot, '.shit-logs');
30
+
31
+ if (!existsSync(shitLogsDir)) {
32
+ issues.push({
33
+ type: 'missing_directory',
34
+ message: '.shit-logs directory not found',
35
+ fix: () => {
36
+ const { mkdirSync } = require('fs');
37
+ mkdirSync(shitLogsDir, { recursive: true });
38
+ return 'Created .shit-logs directory';
39
+ }
40
+ });
41
+ return issues;
42
+ }
43
+
44
+ // Check index.json
45
+ const indexFile = join(shitLogsDir, 'index.json');
46
+ if (!existsSync(indexFile)) {
47
+ issues.push({
48
+ type: 'missing_index',
49
+ message: 'index.json not found',
50
+ fix: () => {
51
+ const initialIndex = {
52
+ version: 2,
53
+ sessions: [],
54
+ files: {},
55
+ created: new Date().toISOString()
56
+ };
57
+ writeFileSync(indexFile, JSON.stringify(initialIndex, null, 2));
58
+ return 'Created index.json';
59
+ }
60
+ });
61
+ } else {
62
+ try {
63
+ JSON.parse(readFileSync(indexFile, 'utf-8'));
64
+ } catch {
65
+ issues.push({
66
+ type: 'corrupted_index',
67
+ message: 'index.json is corrupted',
68
+ fix: () => {
69
+ const backup = `${indexFile}.backup.${Date.now()}`;
70
+ const { copyFileSync } = require('fs');
71
+ copyFileSync(indexFile, backup);
72
+
73
+ const initialIndex = {
74
+ version: 2,
75
+ sessions: [],
76
+ files: {},
77
+ created: new Date().toISOString(),
78
+ recovered: true
79
+ };
80
+ writeFileSync(indexFile, JSON.stringify(initialIndex, null, 2));
81
+ return `Recreated index.json (backup saved as ${backup})`;
82
+ }
83
+ });
84
+ }
85
+ }
86
+
87
+ return issues;
88
+ }
89
+
90
+ function checkSessionDirectories(projectRoot) {
91
+ const issues = [];
92
+ const shitLogsDir = join(projectRoot, '.shit-logs');
93
+
94
+ if (!existsSync(shitLogsDir)) {
95
+ return issues;
96
+ }
97
+
98
+ const entries = readdirSync(shitLogsDir);
99
+ const sessionDirs = entries.filter(name => {
100
+ const fullPath = join(shitLogsDir, name);
101
+ return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
102
+ });
103
+
104
+ for (const sessionDir of sessionDirs) {
105
+ const sessionPath = join(shitLogsDir, sessionDir);
106
+ const stateFile = join(sessionPath, 'state.json');
107
+
108
+ if (!existsSync(stateFile)) {
109
+ issues.push({
110
+ type: 'missing_state',
111
+ message: `Session ${sessionDir} missing state.json`,
112
+ sessionDir,
113
+ fix: () => {
114
+ const defaultState = {
115
+ session_id: sessionDir,
116
+ start_time: new Date().toISOString(),
117
+ event_count: 0,
118
+ files: {},
119
+ recovered: true
120
+ };
121
+ writeFileSync(stateFile, JSON.stringify(defaultState, null, 2));
122
+ return `Created state.json for ${sessionDir}`;
123
+ }
124
+ });
125
+ } else {
126
+ try {
127
+ JSON.parse(readFileSync(stateFile, 'utf-8'));
128
+ } catch {
129
+ issues.push({
130
+ type: 'corrupted_state',
131
+ message: `Session ${sessionDir} has corrupted state.json`,
132
+ sessionDir,
133
+ fix: () => {
134
+ const backup = `${stateFile}.backup.${Date.now()}`;
135
+ const { copyFileSync } = require('fs');
136
+ copyFileSync(stateFile, backup);
137
+
138
+ const defaultState = {
139
+ session_id: sessionDir,
140
+ start_time: new Date().toISOString(),
141
+ event_count: 0,
142
+ files: {},
143
+ recovered: true
144
+ };
145
+ writeFileSync(stateFile, JSON.stringify(defaultState, null, 2));
146
+ return `Recreated state.json for ${sessionDir} (backup: ${backup})`;
147
+ }
148
+ });
149
+ }
150
+ }
151
+ }
152
+
153
+ return issues;
154
+ }
155
+
156
+ function checkOrphanedShadowBranches(projectRoot) {
157
+ const issues = [];
158
+ const shitLogsDir = join(projectRoot, '.shit-logs');
159
+
160
+ try {
161
+ const branches = git('branch --list "shit/*"', projectRoot)
162
+ .split('\n')
163
+ .map(b => b.trim().replace(/^\*?\s*/, ''))
164
+ .filter(Boolean);
165
+
166
+ const sessionDirs = existsSync(shitLogsDir) ?
167
+ readdirSync(shitLogsDir).filter(name => {
168
+ const fullPath = join(shitLogsDir, name);
169
+ return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
170
+ }) : [];
171
+
172
+ for (const branch of branches) {
173
+ const match = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
174
+ if (match) {
175
+ const [, baseCommit, sessionShort] = match;
176
+
177
+ // Check if corresponding session exists
178
+ const hasSession = sessionDirs.some(dir => dir.includes(sessionShort));
179
+
180
+ if (!hasSession) {
181
+ issues.push({
182
+ type: 'orphaned_shadow',
183
+ message: `Orphaned shadow branch: ${branch}`,
184
+ branch,
185
+ fix: () => {
186
+ git(`branch -D ${branch}`, projectRoot);
187
+ return `Deleted orphaned shadow branch: ${branch}`;
188
+ }
189
+ });
190
+ }
191
+ }
192
+ }
193
+ } catch {
194
+ // Git operations failed, skip shadow branch check
195
+ }
196
+
197
+ return issues;
198
+ }
199
+
200
+ function checkStuckSessions(projectRoot) {
201
+ const issues = [];
202
+ const shitLogsDir = join(projectRoot, '.shit-logs');
203
+
204
+ if (!existsSync(shitLogsDir)) {
205
+ return issues;
206
+ }
207
+
208
+ const entries = readdirSync(shitLogsDir);
209
+ const sessionDirs = entries.filter(name => {
210
+ const fullPath = join(shitLogsDir, name);
211
+ return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
212
+ });
213
+
214
+ const now = new Date();
215
+ const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
216
+
217
+ for (const sessionDir of sessionDirs) {
218
+ const sessionPath = join(shitLogsDir, sessionDir);
219
+ const stateFile = join(sessionPath, 'state.json');
220
+
221
+ if (existsSync(stateFile)) {
222
+ try {
223
+ const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
224
+ const startTime = new Date(state.start_time);
225
+
226
+ // Check for sessions older than 24 hours without end_time
227
+ if (!state.end_time && startTime < oneDayAgo) {
228
+ issues.push({
229
+ type: 'stuck_session',
230
+ message: `Stuck session: ${sessionDir} (started ${startTime.toLocaleString()})`,
231
+ sessionDir,
232
+ fix: () => {
233
+ state.end_time = new Date().toISOString();
234
+ state.stuck_session_recovered = true;
235
+ writeFileSync(stateFile, JSON.stringify(state, null, 2));
236
+ return `Marked stuck session as ended: ${sessionDir}`;
237
+ }
238
+ });
239
+ }
240
+ } catch {
241
+ // Already handled by checkSessionDirectories
242
+ }
243
+ }
244
+ }
245
+
246
+ return issues;
247
+ }
248
+
249
+ export default async function doctor(args) {
250
+ try {
251
+ const projectRoot = findProjectRoot();
252
+ const autoFix = args.includes('--fix') || args.includes('--auto-fix');
253
+ const verbose = args.includes('--verbose') || args.includes('-v');
254
+
255
+ console.log('🩺 Running shit-cli diagnostics...\n');
256
+
257
+ // Collect all issues
258
+ const allIssues = [
259
+ ...checkShitLogsStructure(projectRoot),
260
+ ...checkSessionDirectories(projectRoot),
261
+ ...checkOrphanedShadowBranches(projectRoot),
262
+ ...checkStuckSessions(projectRoot)
263
+ ];
264
+
265
+ if (allIssues.length === 0) {
266
+ console.log('✅ No issues found! shit-cli is healthy.');
267
+ return;
268
+ }
269
+
270
+ console.log(`🔍 Found ${allIssues.length} issue(s):\n`);
271
+
272
+ let fixedCount = 0;
273
+ for (const [index, issue] of allIssues.entries()) {
274
+ const prefix = `${index + 1}.`;
275
+ console.log(`${prefix} ${issue.message}`);
276
+
277
+ if (verbose) {
278
+ console.log(` Type: ${issue.type}`);
279
+ if (issue.sessionDir) {
280
+ console.log(` Session: ${issue.sessionDir}`);
281
+ }
282
+ if (issue.branch) {
283
+ console.log(` Branch: ${issue.branch}`);
284
+ }
285
+ }
286
+
287
+ if (autoFix && issue.fix) {
288
+ try {
289
+ const result = issue.fix();
290
+ console.log(` ✅ Fixed: ${result}`);
291
+ fixedCount++;
292
+ } catch (error) {
293
+ console.log(` ❌ Fix failed: ${error.message}`);
294
+ }
295
+ }
296
+
297
+ console.log();
298
+ }
299
+
300
+ if (autoFix) {
301
+ console.log(`🎉 Fixed ${fixedCount}/${allIssues.length} issues.`);
302
+ if (fixedCount < allIssues.length) {
303
+ console.log(' Some issues could not be automatically fixed.');
304
+ }
305
+ } else {
306
+ console.log('💡 To automatically fix these issues, run:');
307
+ console.log(' shit doctor --fix');
308
+ }
309
+
310
+ } catch (error) {
311
+ console.error('❌ Doctor failed:', error.message);
312
+ process.exit(1);
313
+ }
314
+ }
package/lib/enable.js ADDED
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Enable shit-cli in repository
5
+ * Similar to 'entire enable' - sets up hooks and configuration
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { execSync } from 'child_process';
11
+
12
+ function findProjectRoot() {
13
+ let dir = process.cwd();
14
+ while (dir !== '/') {
15
+ if (existsSync(join(dir, '.git'))) {
16
+ return dir;
17
+ }
18
+ dir = join(dir, '..');
19
+ }
20
+ throw new Error('Not in a git repository');
21
+ }
22
+
23
+ function setupClaudeHooks(projectRoot) {
24
+ const claudeDir = join(projectRoot, '.claude');
25
+ const settingsFile = join(claudeDir, 'settings.json');
26
+
27
+ // Create .claude directory if it doesn't exist
28
+ if (!existsSync(claudeDir)) {
29
+ mkdirSync(claudeDir, { recursive: true });
30
+ }
31
+
32
+ let settings = {};
33
+ if (existsSync(settingsFile)) {
34
+ try {
35
+ settings = JSON.parse(readFileSync(settingsFile, 'utf-8'));
36
+ } catch {
37
+ // Invalid JSON, start fresh
38
+ }
39
+ }
40
+
41
+ // Add shit-cli hooks
42
+ if (!settings.hooks) settings.hooks = {};
43
+
44
+ settings.hooks.session_start = 'shit log session_start';
45
+ settings.hooks.session_end = 'shit log session_end';
46
+ settings.hooks.tool_use = 'shit log tool_use';
47
+ settings.hooks.edit_applied = 'shit log edit_applied';
48
+
49
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
50
+ return settingsFile;
51
+ }
52
+
53
+ function initializeShitLogs(projectRoot) {
54
+ const shitDir = join(projectRoot, '.shit-logs');
55
+ if (!existsSync(shitDir)) {
56
+ mkdirSync(shitDir, { recursive: true });
57
+ }
58
+
59
+ // Create index.json if it doesn't exist
60
+ const indexFile = join(shitDir, 'index.json');
61
+ if (!existsSync(indexFile)) {
62
+ const initialIndex = {
63
+ version: 2,
64
+ sessions: [],
65
+ files: {},
66
+ created: new Date().toISOString()
67
+ };
68
+ writeFileSync(indexFile, JSON.stringify(initialIndex, null, 2));
69
+ }
70
+ }
71
+
72
+ export default async function enable(args) {
73
+ try {
74
+ const projectRoot = findProjectRoot();
75
+
76
+ console.log('🔧 Enabling shit-cli in repository...');
77
+
78
+ // Setup Claude Code hooks
79
+ const settingsFile = setupClaudeHooks(projectRoot);
80
+ console.log(`✅ Updated Claude hooks in ${settingsFile}`);
81
+
82
+ // Initialize .shit-logs directory
83
+ initializeShitLogs(projectRoot);
84
+ console.log('✅ Initialized .shit-logs directory');
85
+
86
+ // Add .shit-logs to .gitignore if not already there
87
+ const gitignoreFile = join(projectRoot, '.gitignore');
88
+ let gitignore = '';
89
+ if (existsSync(gitignoreFile)) {
90
+ gitignore = readFileSync(gitignoreFile, 'utf-8');
91
+ }
92
+
93
+ if (!gitignore.includes('.shit-logs')) {
94
+ const newLine = gitignore.endsWith('\n') || gitignore === '' ? '' : '\n';
95
+ writeFileSync(gitignoreFile, gitignore + newLine + '.shit-logs/\n');
96
+ console.log('✅ Added .shit-logs to .gitignore');
97
+ }
98
+
99
+ console.log('\n🎉 shit-cli enabled successfully!');
100
+ console.log('\nNext steps:');
101
+ console.log(' 1. Start a Claude Code session');
102
+ console.log(' 2. Use "shit status" to check session tracking');
103
+ console.log(' 3. Use "shit list" to view logged sessions');
104
+
105
+ } catch (error) {
106
+ console.error('❌ Failed to enable shit-cli:', error.message);
107
+ process.exit(1);
108
+ }
109
+ }
package/lib/resume.js ADDED
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Resume session from checkpoint
5
+ * Similar to 'entire resume' - restore branch and session metadata
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { execSync } from 'child_process';
11
+
12
+ function findProjectRoot() {
13
+ let dir = process.cwd();
14
+ while (dir !== '/') {
15
+ if (existsSync(join(dir, '.git'))) {
16
+ return dir;
17
+ }
18
+ dir = join(dir, '..');
19
+ }
20
+ throw new Error('Not in a git repository');
21
+ }
22
+
23
+ function git(cmd, cwd) {
24
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
25
+ }
26
+
27
+ function findCheckpoint(projectRoot, checkpointId) {
28
+ try {
29
+ const branches = git('branch --list "shit/*"', projectRoot)
30
+ .split('\n')
31
+ .map(b => b.trim().replace(/^\*?\s*/, ''))
32
+ .filter(Boolean);
33
+
34
+ for (const branch of branches) {
35
+ const match = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
36
+ if (match) {
37
+ const [, baseCommit, sessionShort] = match;
38
+ if (sessionShort.startsWith(checkpointId) || baseCommit.startsWith(checkpointId)) {
39
+ const log = git(`log ${branch} --oneline -1`, projectRoot);
40
+ const timestamp = git(`log ${branch} --format=%ci -1`, projectRoot);
41
+
42
+ return {
43
+ branch,
44
+ baseCommit,
45
+ sessionShort,
46
+ timestamp,
47
+ message: log.split(' ').slice(1).join(' ')
48
+ };
49
+ }
50
+ }
51
+ }
52
+ return null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function extractSessionFromShadow(projectRoot, checkpoint) {
59
+ try {
60
+ // Get files from shadow branch
61
+ const files = git(`ls-tree -r --name-only ${checkpoint.branch}`, projectRoot)
62
+ .split('\n')
63
+ .filter(Boolean);
64
+
65
+ const sessionData = {};
66
+
67
+ for (const file of files) {
68
+ if (file.startsWith('.shit-logs/')) {
69
+ try {
70
+ const content = git(`show ${checkpoint.branch}:${file}`, projectRoot);
71
+ sessionData[file] = content;
72
+ } catch {
73
+ // Skip files that can't be read
74
+ }
75
+ }
76
+ }
77
+
78
+ return sessionData;
79
+ } catch {
80
+ return {};
81
+ }
82
+ }
83
+
84
+ function restoreSessionData(projectRoot, sessionData, newSessionId) {
85
+ const shitLogsDir = join(projectRoot, '.shit-logs');
86
+ if (!existsSync(shitLogsDir)) {
87
+ mkdirSync(shitLogsDir, { recursive: true });
88
+ }
89
+
90
+ const sessionDir = join(shitLogsDir, newSessionId);
91
+ if (!existsSync(sessionDir)) {
92
+ mkdirSync(sessionDir, { recursive: true });
93
+ }
94
+
95
+ let restoredFiles = 0;
96
+
97
+ for (const [filePath, content] of Object.entries(sessionData)) {
98
+ // Extract relative path within session
99
+ const match = filePath.match(/^\.shit-logs\/[^/]+\/(.+)$/);
100
+ if (match) {
101
+ const relativePath = match[1];
102
+ const targetPath = join(sessionDir, relativePath);
103
+
104
+ // Create directory if needed
105
+ const targetDir = join(targetPath, '..');
106
+ if (!existsSync(targetDir)) {
107
+ mkdirSync(targetDir, { recursive: true });
108
+ }
109
+
110
+ writeFileSync(targetPath, content);
111
+ restoredFiles++;
112
+ }
113
+ }
114
+
115
+ return { sessionDir, restoredFiles };
116
+ }
117
+
118
+ function generateSessionId() {
119
+ const now = new Date();
120
+ const date = now.toISOString().split('T')[0]; // YYYY-MM-DD
121
+ const uuid = Math.random().toString(36).substring(2, 15) +
122
+ Math.random().toString(36).substring(2, 15);
123
+ return `${date}-${uuid}`;
124
+ }
125
+
126
+ function updateSessionState(sessionDir, checkpoint) {
127
+ const stateFile = join(sessionDir, 'state.json');
128
+
129
+ let state = {};
130
+ if (existsSync(stateFile)) {
131
+ try {
132
+ state = JSON.parse(readFileSync(stateFile, 'utf-8'));
133
+ } catch {
134
+ // Invalid JSON, start fresh
135
+ }
136
+ }
137
+
138
+ // Update state for resumed session
139
+ state.resumed_from = checkpoint.sessionShort;
140
+ state.resumed_at = new Date().toISOString();
141
+ state.original_checkpoint = checkpoint.branch;
142
+
143
+ // Reset some fields for new session
144
+ delete state.end_time;
145
+ delete state.shadow_branch;
146
+
147
+ writeFileSync(stateFile, JSON.stringify(state, null, 2));
148
+ }
149
+
150
+ export default async function resume(args) {
151
+ try {
152
+ const projectRoot = findProjectRoot();
153
+ const checkpointId = args.find(arg => !arg.startsWith('-'));
154
+
155
+ if (!checkpointId) {
156
+ console.error('❌ Please specify a checkpoint ID');
157
+ console.error(' Usage: shit resume <checkpoint-id>');
158
+ console.error(' Use "shit list" to see available checkpoints');
159
+ process.exit(1);
160
+ }
161
+
162
+ console.log(`🔍 Looking for checkpoint: ${checkpointId}`);
163
+
164
+ const checkpoint = findCheckpoint(projectRoot, checkpointId);
165
+ if (!checkpoint) {
166
+ console.error(`❌ Checkpoint not found: ${checkpointId}`);
167
+ console.error(' Use "shit shadow" to list available checkpoints');
168
+ process.exit(1);
169
+ }
170
+
171
+ console.log(`✅ Found checkpoint: ${checkpoint.sessionShort}`);
172
+ console.log(` Branch: ${checkpoint.branch}`);
173
+ console.log(` Base commit: ${checkpoint.baseCommit}`);
174
+ console.log(` Created: ${new Date(checkpoint.timestamp).toLocaleString()}`);
175
+ console.log();
176
+
177
+ // Check if we're already at the right commit
178
+ const currentCommit = git('rev-parse HEAD', projectRoot);
179
+ if (!currentCommit.startsWith(checkpoint.baseCommit)) {
180
+ console.log(`🔄 Checking out base commit: ${checkpoint.baseCommit}`);
181
+ git(`checkout ${checkpoint.baseCommit}`, projectRoot);
182
+ }
183
+
184
+ // Extract session data from shadow branch
185
+ console.log('📦 Extracting session data from shadow branch...');
186
+ const sessionData = extractSessionFromShadow(projectRoot, checkpoint);
187
+
188
+ if (Object.keys(sessionData).length === 0) {
189
+ console.error('❌ No session data found in shadow branch');
190
+ process.exit(1);
191
+ }
192
+
193
+ // Create new session ID for resumed session
194
+ const newSessionId = generateSessionId();
195
+ console.log(`🆕 Creating new session: ${newSessionId}`);
196
+
197
+ // Restore session data
198
+ const { sessionDir, restoredFiles } = restoreSessionData(projectRoot, sessionData, newSessionId);
199
+ console.log(`✅ Restored ${restoredFiles} files to ${sessionDir}`);
200
+
201
+ // Update session state
202
+ updateSessionState(sessionDir, checkpoint);
203
+ console.log('✅ Updated session state');
204
+
205
+ console.log();
206
+ console.log('🎉 Session resumed successfully!');
207
+ console.log(` New session ID: ${newSessionId}`);
208
+ console.log(` Resumed from: ${checkpoint.sessionShort}`);
209
+ console.log();
210
+ console.log('💡 Next steps:');
211
+ console.log(' 1. Start Claude Code to continue the session');
212
+ console.log(' 2. Use "shit status" to verify session tracking');
213
+ console.log(' 3. Use "shit view" to see session history');
214
+
215
+ } catch (error) {
216
+ console.error('❌ Failed to resume session:', error.message);
217
+ process.exit(1);
218
+ }
219
+ }
package/lib/rewind.js ADDED
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Rewind to previous checkpoint
5
+ * Similar to 'entire rewind' - rollback to known good state
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { execSync } from 'child_process';
11
+
12
+ function findProjectRoot() {
13
+ let dir = process.cwd();
14
+ while (dir !== '/') {
15
+ if (existsSync(join(dir, '.git'))) {
16
+ return dir;
17
+ }
18
+ dir = join(dir, '..');
19
+ }
20
+ throw new Error('Not in a git repository');
21
+ }
22
+
23
+ function git(cmd, cwd) {
24
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
25
+ }
26
+
27
+ function listCheckpoints(projectRoot) {
28
+ try {
29
+ // Get shadow branches
30
+ const branches = git('branch --list "shit/*"', projectRoot)
31
+ .split('\n')
32
+ .map(b => b.trim().replace(/^\*?\s*/, ''))
33
+ .filter(Boolean);
34
+
35
+ const checkpoints = [];
36
+
37
+ for (const branch of branches) {
38
+ try {
39
+ const log = git(`log ${branch} --oneline -1`, projectRoot);
40
+ const [commit, ...messageParts] = log.split(' ');
41
+ const message = messageParts.join(' ');
42
+
43
+ // Extract session info from branch name: shit/<commit>-<session>
44
+ const match = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
45
+ if (match) {
46
+ const [, baseCommit, sessionShort] = match;
47
+ checkpoints.push({
48
+ branch,
49
+ commit,
50
+ baseCommit,
51
+ sessionShort,
52
+ message,
53
+ timestamp: git(`log ${branch} --format=%ci -1`, projectRoot)
54
+ });
55
+ }
56
+ } catch {
57
+ // Skip invalid branches
58
+ }
59
+ }
60
+
61
+ return checkpoints.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ function getCurrentCommit(projectRoot) {
68
+ try {
69
+ return git('rev-parse HEAD', projectRoot);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function hasUncommittedChanges(projectRoot) {
76
+ try {
77
+ const status = git('status --porcelain', projectRoot);
78
+ return status.length > 0;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ function rewindToCheckpoint(projectRoot, checkpoint, force = false) {
85
+ const currentCommit = getCurrentCommit(projectRoot);
86
+
87
+ if (!force && hasUncommittedChanges(projectRoot)) {
88
+ throw new Error('Working directory has uncommitted changes. Use --force to override or commit changes first.');
89
+ }
90
+
91
+ // Reset to the base commit of the checkpoint
92
+ git(`reset --hard ${checkpoint.baseCommit}`, projectRoot);
93
+
94
+ console.log(`✅ Rewound to checkpoint ${checkpoint.sessionShort}`);
95
+ console.log(` Base commit: ${checkpoint.baseCommit}`);
96
+ console.log(` Previous HEAD: ${currentCommit}`);
97
+
98
+ return currentCommit;
99
+ }
100
+
101
+ export default async function rewind(args) {
102
+ try {
103
+ const projectRoot = findProjectRoot();
104
+ const force = args.includes('--force') || args.includes('-f');
105
+ const interactive = args.includes('--interactive') || args.includes('-i');
106
+
107
+ // Get target checkpoint
108
+ let targetCheckpoint = null;
109
+ const checkpointArg = args.find(arg => !arg.startsWith('-'));
110
+
111
+ const checkpoints = listCheckpoints(projectRoot);
112
+
113
+ if (checkpoints.length === 0) {
114
+ console.log('❌ No checkpoints found');
115
+ console.log(' Checkpoints are created automatically when sessions end.');
116
+ process.exit(1);
117
+ }
118
+
119
+ if (checkpointArg) {
120
+ // Find specific checkpoint
121
+ targetCheckpoint = checkpoints.find(cp =>
122
+ cp.sessionShort.startsWith(checkpointArg) ||
123
+ cp.baseCommit.startsWith(checkpointArg)
124
+ );
125
+
126
+ if (!targetCheckpoint) {
127
+ console.error(`❌ Checkpoint not found: ${checkpointArg}`);
128
+ process.exit(1);
129
+ }
130
+ } else if (interactive) {
131
+ // Interactive selection
132
+ console.log('📋 Available checkpoints:\n');
133
+ checkpoints.forEach((cp, i) => {
134
+ const date = new Date(cp.timestamp).toLocaleString();
135
+ console.log(`${i + 1}. ${cp.sessionShort} (${cp.baseCommit.slice(0, 7)}) - ${date}`);
136
+ console.log(` ${cp.message}`);
137
+ console.log();
138
+ });
139
+
140
+ // For now, just use the most recent
141
+ targetCheckpoint = checkpoints[0];
142
+ console.log(`Using most recent checkpoint: ${targetCheckpoint.sessionShort}`);
143
+ } else {
144
+ // Use most recent checkpoint
145
+ targetCheckpoint = checkpoints[0];
146
+ }
147
+
148
+ console.log(`🔄 Rewinding to checkpoint: ${targetCheckpoint.sessionShort}`);
149
+ console.log(` Branch: ${targetCheckpoint.branch}`);
150
+ console.log(` Base commit: ${targetCheckpoint.baseCommit}`);
151
+ console.log(` Created: ${new Date(targetCheckpoint.timestamp).toLocaleString()}`);
152
+ console.log();
153
+
154
+ if (!force && hasUncommittedChanges(projectRoot)) {
155
+ console.error('❌ Working directory has uncommitted changes.');
156
+ console.error(' Commit your changes or use --force to discard them.');
157
+ process.exit(1);
158
+ }
159
+
160
+ const previousCommit = rewindToCheckpoint(projectRoot, targetCheckpoint, force);
161
+
162
+ console.log();
163
+ console.log('💡 To undo this rewind:');
164
+ console.log(` git reset --hard ${previousCommit}`);
165
+ console.log();
166
+ console.log('💡 To resume from this checkpoint:');
167
+ console.log(` shit resume ${targetCheckpoint.sessionShort}`);
168
+
169
+ } catch (error) {
170
+ console.error('❌ Failed to rewind:', error.message);
171
+ process.exit(1);
172
+ }
173
+ }
package/lib/status.js ADDED
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Show current session status
5
+ * Similar to 'entire status' - displays active session info
6
+ */
7
+
8
+ import { existsSync, readFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { execSync } from 'child_process';
11
+
12
+ function findProjectRoot() {
13
+ let dir = process.cwd();
14
+ while (dir !== '/') {
15
+ if (existsSync(join(dir, '.git'))) {
16
+ return dir;
17
+ }
18
+ dir = join(dir, '..');
19
+ }
20
+ throw new Error('Not in a git repository');
21
+ }
22
+
23
+ function getCurrentSession(projectRoot) {
24
+ const shitLogsDir = join(projectRoot, '.shit-logs');
25
+ if (!existsSync(shitLogsDir)) {
26
+ return null;
27
+ }
28
+
29
+ // Find the most recent session directory
30
+ const { readdirSync, statSync } = await import('fs');
31
+ const sessions = readdirSync(shitLogsDir)
32
+ .filter(name => name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/)) // session format
33
+ .map(name => ({
34
+ name,
35
+ path: join(shitLogsDir, name),
36
+ mtime: statSync(join(shitLogsDir, name)).mtime
37
+ }))
38
+ .sort((a, b) => b.mtime - a.mtime);
39
+
40
+ if (sessions.length === 0) {
41
+ return null;
42
+ }
43
+
44
+ const latestSession = sessions[0];
45
+ const stateFile = join(latestSession.path, 'state.json');
46
+
47
+ if (!existsSync(stateFile)) {
48
+ return { ...latestSession, state: null };
49
+ }
50
+
51
+ try {
52
+ const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
53
+ return { ...latestSession, state };
54
+ } catch {
55
+ return { ...latestSession, state: null };
56
+ }
57
+ }
58
+
59
+ function getGitInfo(projectRoot) {
60
+ try {
61
+ const branch = execSync('git branch --show-current', { cwd: projectRoot, encoding: 'utf-8' }).trim();
62
+ const commit = execSync('git rev-parse --short HEAD', { cwd: projectRoot, encoding: 'utf-8' }).trim();
63
+ const status = execSync('git status --porcelain', { cwd: projectRoot, encoding: 'utf-8' }).trim();
64
+
65
+ return {
66
+ branch,
67
+ commit,
68
+ dirty: status.length > 0,
69
+ changes: status.split('\n').filter(Boolean).length
70
+ };
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function formatDuration(startTime) {
77
+ if (!startTime) return 'unknown';
78
+
79
+ const start = new Date(startTime);
80
+ const now = new Date();
81
+ const diff = now - start;
82
+
83
+ const minutes = Math.floor(diff / 60000);
84
+ const hours = Math.floor(minutes / 60);
85
+
86
+ if (hours > 0) {
87
+ return `${hours}h ${minutes % 60}m`;
88
+ } else {
89
+ return `${minutes}m`;
90
+ }
91
+ }
92
+
93
+ export default async function status(args) {
94
+ try {
95
+ const projectRoot = findProjectRoot();
96
+ const gitInfo = getGitInfo(projectRoot);
97
+ const currentSession = await getCurrentSession(projectRoot);
98
+
99
+ console.log('📊 shit-cli Status\n');
100
+
101
+ // Git info
102
+ if (gitInfo) {
103
+ console.log(`📂 Repository: ${gitInfo.branch} @ ${gitInfo.commit}`);
104
+ if (gitInfo.dirty) {
105
+ console.log(`⚠️ Working tree: ${gitInfo.changes} uncommitted changes`);
106
+ } else {
107
+ console.log('✅ Working tree: clean');
108
+ }
109
+ } else {
110
+ console.log('❌ Git repository: not found');
111
+ }
112
+
113
+ console.log();
114
+
115
+ // Session info
116
+ if (currentSession) {
117
+ console.log(`🎯 Current Session: ${currentSession.name}`);
118
+
119
+ if (currentSession.state) {
120
+ const state = currentSession.state;
121
+ console.log(` Started: ${new Date(state.start_time).toLocaleString()}`);
122
+ console.log(` Duration: ${formatDuration(state.start_time)}`);
123
+ console.log(` Events: ${state.event_count || 0}`);
124
+ console.log(` Files: ${Object.keys(state.files || {}).length}`);
125
+
126
+ if (state.shadow_branch) {
127
+ console.log(` Shadow: ${state.shadow_branch}`);
128
+ }
129
+
130
+ if (state.session_type) {
131
+ console.log(` Type: ${state.session_type}`);
132
+ }
133
+
134
+ if (state.risk_level) {
135
+ const riskEmoji = state.risk_level === 'high' ? '🔴' :
136
+ state.risk_level === 'medium' ? '🟡' : '🟢';
137
+ console.log(` Risk: ${riskEmoji} ${state.risk_level}`);
138
+ }
139
+ } else {
140
+ console.log(' State: no state file found');
141
+ }
142
+ } else {
143
+ console.log('💤 No active session found');
144
+ console.log(' Run "shit enable" to start tracking sessions');
145
+ }
146
+
147
+ // Check if shit-cli is enabled
148
+ const claudeSettings = join(projectRoot, '.claude', 'settings.json');
149
+ if (existsSync(claudeSettings)) {
150
+ try {
151
+ const settings = JSON.parse(readFileSync(claudeSettings, 'utf-8'));
152
+ const hasHooks = settings.hooks &&
153
+ (settings.hooks.session_start || settings.hooks.session_end);
154
+
155
+ console.log();
156
+ if (hasHooks) {
157
+ console.log('✅ shit-cli: enabled and configured');
158
+ } else {
159
+ console.log('⚠️ shit-cli: not configured (run "shit enable")');
160
+ }
161
+ } catch {
162
+ console.log('⚠️ shit-cli: configuration error');
163
+ }
164
+ } else {
165
+ console.log();
166
+ console.log('❌ shit-cli: not enabled (run "shit enable")');
167
+ }
168
+
169
+ } catch (error) {
170
+ console.error('❌ Failed to get status:', error.message);
171
+ process.exit(1);
172
+ }
173
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudemini/shit-cli",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Session-based Hook Intelligence Tracker - CLI tool for logging Claude Code hooks",
5
5
  "type": "module",
6
6
  "bin": {