@claudemini/shit-cli 1.3.0 → 1.5.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/lib/doctor.js CHANGED
@@ -5,20 +5,10 @@
5
5
  * Similar to 'entire doctor' - repairs corrupted state and cleans up
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
8
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
9
9
  import { join } from 'path';
10
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
- }
11
+ import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
22
12
 
23
13
  function git(cmd, cwd) {
24
14
  return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
@@ -33,7 +23,6 @@ function checkShitLogsStructure(projectRoot) {
33
23
  type: 'missing_directory',
34
24
  message: '.shit-logs directory not found',
35
25
  fix: () => {
36
- const { mkdirSync } = require('fs');
37
26
  mkdirSync(shitLogsDir, { recursive: true });
38
27
  return 'Created .shit-logs directory';
39
28
  }
@@ -67,7 +56,6 @@ function checkShitLogsStructure(projectRoot) {
67
56
  message: 'index.json is corrupted',
68
57
  fix: () => {
69
58
  const backup = `${indexFile}.backup.${Date.now()}`;
70
- const { copyFileSync } = require('fs');
71
59
  copyFileSync(indexFile, backup);
72
60
 
73
61
  const initialIndex = {
@@ -98,7 +86,7 @@ function checkSessionDirectories(projectRoot) {
98
86
  const entries = readdirSync(shitLogsDir);
99
87
  const sessionDirs = entries.filter(name => {
100
88
  const fullPath = join(shitLogsDir, name);
101
- return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
89
+ return statSync(fullPath).isDirectory() && SESSION_ID_REGEX.test(name);
102
90
  });
103
91
 
104
92
  for (const sessionDir of sessionDirs) {
@@ -132,7 +120,6 @@ function checkSessionDirectories(projectRoot) {
132
120
  sessionDir,
133
121
  fix: () => {
134
122
  const backup = `${stateFile}.backup.${Date.now()}`;
135
- const { copyFileSync } = require('fs');
136
123
  copyFileSync(stateFile, backup);
137
124
 
138
125
  const defaultState = {
@@ -166,7 +153,7 @@ function checkOrphanedShadowBranches(projectRoot) {
166
153
  const sessionDirs = existsSync(shitLogsDir) ?
167
154
  readdirSync(shitLogsDir).filter(name => {
168
155
  const fullPath = join(shitLogsDir, name);
169
- return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
156
+ return statSync(fullPath).isDirectory() && SESSION_ID_REGEX.test(name);
170
157
  }) : [];
171
158
 
172
159
  for (const branch of branches) {
@@ -208,11 +195,12 @@ function checkStuckSessions(projectRoot) {
208
195
  const entries = readdirSync(shitLogsDir);
209
196
  const sessionDirs = entries.filter(name => {
210
197
  const fullPath = join(shitLogsDir, name);
211
- return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
198
+ return statSync(fullPath).isDirectory() && SESSION_ID_REGEX.test(name);
212
199
  });
213
200
 
214
201
  const now = new Date();
215
202
  const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
203
+ const endedHookTypes = new Set(['session-end', 'SessionEnd', 'stop', 'session_end', 'end']);
216
204
 
217
205
  for (const sessionDir of sessionDirs) {
218
206
  const sessionPath = join(shitLogsDir, sessionDir);
@@ -221,13 +209,18 @@ function checkStuckSessions(projectRoot) {
221
209
  if (existsSync(stateFile)) {
222
210
  try {
223
211
  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) {
212
+ const lastActivity = new Date(state.last_time || state.start_time);
213
+ const hasValidLastActivity = !Number.isNaN(lastActivity.getTime());
214
+ const endedByHook = endedHookTypes.has(state.last_hook_type);
215
+ const hasShadowBranch = typeof state.shadow_branch === 'string' && state.shadow_branch.length > 0;
216
+ const hasCheckpoint = Array.isArray(state.checkpoints) && state.checkpoints.length > 0;
217
+ const consideredEnded = Boolean(state.end_time) || endedByHook || hasShadowBranch || hasCheckpoint;
218
+
219
+ // Stuck means inactive for >24h and not explicitly/implicitly ended.
220
+ if (!consideredEnded && hasValidLastActivity && lastActivity < oneDayAgo) {
228
221
  issues.push({
229
222
  type: 'stuck_session',
230
- message: `Stuck session: ${sessionDir} (started ${startTime.toLocaleString()})`,
223
+ message: `Stuck session: ${sessionDir} (last activity ${lastActivity.toLocaleString()})`,
231
224
  sessionDir,
232
225
  fix: () => {
233
226
  state.end_time = new Date().toISOString();
@@ -248,7 +241,7 @@ function checkStuckSessions(projectRoot) {
248
241
 
249
242
  export default async function doctor(args) {
250
243
  try {
251
- const projectRoot = findProjectRoot();
244
+ const projectRoot = getProjectRoot();
252
245
  const autoFix = args.includes('--fix') || args.includes('--auto-fix');
253
246
  const verbose = args.includes('--verbose') || args.includes('-v');
254
247
 
package/lib/enable.js CHANGED
@@ -9,17 +9,7 @@
9
9
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
10
10
  import { join } from 'path';
11
11
  import { execSync } from 'child_process';
12
-
13
- function findProjectRoot() {
14
- let dir = process.cwd();
15
- while (dir !== '/') {
16
- if (existsSync(join(dir, '.git'))) {
17
- return dir;
18
- }
19
- dir = join(dir, '..');
20
- }
21
- throw new Error('Not in a git repository');
22
- }
12
+ import { getProjectRoot } from './config.js';
23
13
 
24
14
  // Agent-specific hook configurations
25
15
  const AGENT_HOOKS = {
@@ -95,9 +85,30 @@ function setupAgentHooks(projectRoot, agentType) {
95
85
  }
96
86
 
97
87
  // Add shit-cli hooks
98
- if (!settings.hooks) settings.hooks = {};
88
+ if (!settings.hooks) {
89
+ settings.hooks = {};
90
+ }
99
91
 
100
92
  for (const [hookName, command] of Object.entries(config.hooks)) {
93
+ if (agentType === 'claude-code') {
94
+ const current = settings.hooks[hookName];
95
+ const existing = Array.isArray(current)
96
+ ? current
97
+ : (typeof current === 'string'
98
+ ? [{ matcher: '', hooks: [{ type: 'command', command: current }] }]
99
+ : []);
100
+ const alreadyExists = existing.some(entry =>
101
+ Array.isArray(entry?.hooks) &&
102
+ entry.hooks.some(hook => typeof hook?.command === 'string' && hook.command.includes('shit log'))
103
+ );
104
+
105
+ if (!alreadyExists) {
106
+ existing.push({ matcher: '', hooks: [{ type: 'command', command }] });
107
+ }
108
+ settings.hooks[hookName] = existing;
109
+ continue;
110
+ }
111
+
101
112
  settings.hooks[hookName] = command;
102
113
  }
103
114
 
@@ -164,7 +175,7 @@ function initializeShitLogs(projectRoot) {
164
175
 
165
176
  export default async function enable(args) {
166
177
  try {
167
- const projectRoot = findProjectRoot();
178
+ const projectRoot = getProjectRoot();
168
179
 
169
180
  // Parse arguments
170
181
  const agents = [];
@@ -173,6 +184,7 @@ export default async function enable(args) {
173
184
  let force = false;
174
185
  let pushSessions = true;
175
186
  let telemetry = true;
187
+ let summarize = true;
176
188
 
177
189
  for (const arg of args) {
178
190
  if (arg === '--all') {
@@ -188,6 +200,8 @@ export default async function enable(args) {
188
200
  force = true;
189
201
  } else if (arg === '--skip-push-sessions') {
190
202
  pushSessions = false;
203
+ } else if (arg === '--no-summarize') {
204
+ summarize = false;
191
205
  } else if (arg.startsWith('--telemetry=')) {
192
206
  telemetry = arg.split('=')[1] !== 'false';
193
207
  } else if (arg === '--telemetry') {
@@ -213,6 +227,7 @@ export default async function enable(args) {
213
227
  const configData = {
214
228
  enabled: true,
215
229
  push_sessions: pushSessions,
230
+ summarize: summarize,
216
231
  telemetry: telemetry,
217
232
  log_level: 'info'
218
233
  };
@@ -264,8 +279,13 @@ export default async function enable(args) {
264
279
  console.log(' shit checkpoints # List all checkpoints');
265
280
  console.log(' shit commit # Manually create checkpoint after git commit');
266
281
  console.log('\nOptions:');
267
- console.log(' --all # Enable for all supported agents');
282
+ console.log(' --all # Enable for all supported agents');
268
283
  console.log(' --checkpoint, -c # Enable automatic checkpoint on git commit');
284
+ console.log(' --no-summarize # Disable AI summary generation');
285
+ console.log(' --skip-push-sessions # Disable auto-push to remote');
286
+ console.log(' --telemetry=false # Disable anonymous telemetry');
287
+ console.log('\nAI Summary:');
288
+ console.log(' Set OPENAI_API_KEY or ANTHROPIC_API_KEY to enable AI summaries');
269
289
 
270
290
  } catch (error) {
271
291
  console.error('❌ Failed to enable shit-cli:', error.message);
package/lib/explain.js CHANGED
@@ -5,19 +5,25 @@
5
5
  * Provides human-readable explanation of what happened in a session
6
6
  */
7
7
 
8
- import { existsSync, readFileSync } from 'fs';
8
+ import { existsSync, readFileSync, readdirSync } from 'fs';
9
9
  import { join } from 'path';
10
- import { execSync } from 'child_process';
10
+ import { execFileSync } from 'child_process';
11
+ import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
11
12
 
12
- function findProjectRoot() {
13
- let dir = process.cwd();
14
- while (dir !== '/') {
15
- if (existsSync(join(dir, '.git'))) {
16
- return dir;
13
+ function git(args, cwd, ignoreError = false) {
14
+ try {
15
+ return execFileSync('git', args, {
16
+ cwd,
17
+ encoding: 'utf-8',
18
+ timeout: 10000,
19
+ stdio: ['pipe', 'pipe', 'pipe'],
20
+ }).trim();
21
+ } catch (error) {
22
+ if (ignoreError) {
23
+ return null;
17
24
  }
18
- dir = join(dir, '..');
25
+ throw error;
19
26
  }
20
- throw new Error('Not in a git repository');
21
27
  }
22
28
 
23
29
  function getSessionData(sessionId, projectRoot) {
@@ -41,7 +47,6 @@ function getSessionData(sessionId, projectRoot) {
41
47
  }
42
48
 
43
49
  function explainFromSummary(summaryText) {
44
- const lines = summaryText.split('\n');
45
50
  const explanation = [];
46
51
 
47
52
  // Extract key information
@@ -113,23 +118,20 @@ function explainFromSummary(summaryText) {
113
118
  function explainFromCommit(commitSha, projectRoot) {
114
119
  try {
115
120
  // Try to find associated checkpoint
116
- const checkpoints = execSync('git branch --list "shit/checkpoints/v1/*"', {
117
- cwd: projectRoot,
118
- encoding: 'utf-8'
119
- }).split('\n').filter(Boolean);
121
+ const checkpoints = (git(['branch', '--list', 'shit/checkpoints/v1/*'], projectRoot, true) || '')
122
+ .split('\n')
123
+ .map(line => line.trim().replace(/^\*?\s*/, ''))
124
+ .filter(Boolean);
120
125
 
121
126
  for (const branch of checkpoints) {
122
127
  try {
123
- const log = execSync(`git log ${branch} --oneline -1`, {
124
- cwd: projectRoot,
125
- encoding: 'utf-8'
126
- }).trim();
128
+ const log = git(['log', branch, '--oneline', '-1'], projectRoot, true);
129
+ if (!log) {
130
+ continue;
131
+ }
127
132
 
128
133
  if (log.includes(commitSha.slice(0, 12))) {
129
- const message = execSync(`git log ${branch} --format=%B -1`, {
130
- cwd: projectRoot,
131
- encoding: 'utf-8'
132
- }).trim();
134
+ const message = git(['log', branch, '--format=%B', '-1'], projectRoot, true) || '';
133
135
 
134
136
  return `📸 This commit has an associated checkpoint:\n\nBranch: ${branch}\n\n${message}`;
135
137
  }
@@ -146,13 +148,13 @@ function explainFromCommit(commitSha, projectRoot) {
146
148
 
147
149
  export default async function explain(args) {
148
150
  try {
149
- const projectRoot = findProjectRoot();
151
+ const projectRoot = getProjectRoot();
150
152
  const target = args[0];
151
153
 
152
154
  if (!target) {
153
155
  console.log('Usage: shit explain <session-id | commit-sha>');
154
156
  console.log('\nExamples:');
155
- console.log(' shit explain 2026-02-28-abc12345 # Explain a session');
157
+ console.log(' shit explain b5613b31-c732-4546-9be5-f8ae36f2327f # Explain a session');
156
158
  console.log(' shit explain abc1234 # Explain a commit');
157
159
  process.exit(1);
158
160
  }
@@ -170,23 +172,26 @@ export default async function explain(args) {
170
172
  }
171
173
 
172
174
  // Try as commit
173
- try {
174
- execSync('git rev-parse --verify ' + target + '^{commit}', {
175
- cwd: projectRoot,
176
- stdio: 'ignore'
177
- });
178
-
179
- console.log('📸 Commit Explanation\n');
180
- console.log(explainFromCommit(target, projectRoot));
181
- return;
182
- } catch {
183
- // Not a commit
175
+ const isCommitLike = /^[a-f0-9]{4,40}$/i.test(target);
176
+ if (isCommitLike) {
177
+ const resolvedCommit = git(['rev-parse', '--verify', `${target}^{commit}`], projectRoot, true);
178
+ if (resolvedCommit) {
179
+ console.log('📸 Commit Explanation\n');
180
+ console.log(explainFromCommit(resolvedCommit, projectRoot));
181
+ return;
182
+ }
184
183
  }
185
184
 
185
+ // Not a commit
186
+
186
187
  // Try partial match
187
- const sessions = execSync('ls .shit-logs', { cwd: projectRoot, encoding: 'utf-8' })
188
- .split('\n')
189
- .filter(s => s.includes(target));
188
+ const logDir = join(projectRoot, '.shit-logs');
189
+ const sessions = existsSync(logDir)
190
+ ? readdirSync(logDir, { withFileTypes: true })
191
+ .filter(entry => entry.isDirectory() && SESSION_ID_REGEX.test(entry.name))
192
+ .map(entry => entry.name)
193
+ .filter(name => name.includes(target))
194
+ : [];
190
195
 
191
196
  if (sessions.length > 0) {
192
197
  const matchedSession = sessions[0];
package/lib/reset.js CHANGED
@@ -5,20 +5,10 @@
5
5
  * Similar to 'entire reset' - removes shadow branch and session state
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, rmSync } from 'fs';
8
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
9
9
  import { join } from 'path';
10
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
- }
11
+ import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
22
12
 
23
13
  function git(cmd, cwd) {
24
14
  return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
@@ -53,9 +43,11 @@ function findActiveSession(projectRoot) {
53
43
  return null;
54
44
  }
55
45
 
56
- const { readdirSync, statSync } = require('fs');
57
46
  const sessions = readdirSync(shitLogsDir)
58
- .filter(name => name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/))
47
+ .filter(name => {
48
+ const fullPath = join(shitLogsDir, name);
49
+ return SESSION_ID_REGEX.test(name) && statSync(fullPath).isDirectory();
50
+ })
59
51
  .map(name => ({
60
52
  name,
61
53
  path: join(shitLogsDir, name),
@@ -68,7 +60,7 @@ function findActiveSession(projectRoot) {
68
60
 
69
61
  export default async function reset(args) {
70
62
  try {
71
- const projectRoot = findProjectRoot();
63
+ const projectRoot = getProjectRoot();
72
64
  const force = args.includes('--force') || args.includes('-f');
73
65
 
74
66
  // Get current commit
@@ -112,7 +104,7 @@ export default async function reset(args) {
112
104
  if (state.checkpoints && state.checkpoints.some(cp => cp.linked_commit === commitSha)) {
113
105
  // Remove checkpoint references
114
106
  state.checkpoints = state.checkpoints.filter(cp => cp.linked_commit !== commitSha);
115
- require('fs').writeFileSync(stateFile, JSON.stringify(state, null, 2));
107
+ writeFileSync(stateFile, JSON.stringify(state, null, 2));
116
108
  console.log('✅ Updated session state');
117
109
  }
118
110
  } catch {
package/lib/resume.js CHANGED
@@ -8,17 +8,8 @@
8
8
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
9
9
  import { join } from 'path';
10
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
- }
11
+ import { randomUUID } from 'crypto';
12
+ import { getProjectRoot } from './config.js';
22
13
 
23
14
  function git(cmd, cwd) {
24
15
  return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
@@ -26,19 +17,37 @@ function git(cmd, cwd) {
26
17
 
27
18
  function findCheckpoint(projectRoot, checkpointId) {
28
19
  try {
29
- const branches = git('branch --list "shit/*"', projectRoot)
20
+ const branches = git('branch --list "shit/checkpoints/v1/*" "shit/*"', projectRoot)
30
21
  .split('\n')
31
22
  .map(b => b.trim().replace(/^\*?\s*/, ''))
32
23
  .filter(Boolean);
33
24
 
34
25
  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);
26
+ const timestamp = git(`log ${branch} --format=%ci -1`, projectRoot);
27
+ const log = git(`log ${branch} --oneline -1`, projectRoot);
28
+
29
+ const checkpointMatch = branch.match(/^shit\/checkpoints\/v1\/(\d{4}-\d{2}-\d{2})-([a-f0-9]+)$/);
30
+ if (checkpointMatch) {
31
+ const branchKey = `${checkpointMatch[1]}-${checkpointMatch[2]}`;
32
+ const message = git(`log ${branch} --format=%B -1`, projectRoot);
33
+ const linkedMatch = message.match(/@ ([a-f0-9]+)/);
34
+ const baseCommit = linkedMatch ? linkedMatch[1] : null;
35
+
36
+ if (branchKey.startsWith(checkpointId) || checkpointMatch[2].startsWith(checkpointId) || (baseCommit && baseCommit.startsWith(checkpointId))) {
37
+ return {
38
+ branch,
39
+ baseCommit,
40
+ sessionShort: checkpointMatch[2],
41
+ timestamp,
42
+ message: log.split(' ').slice(1).join(' ')
43
+ };
44
+ }
45
+ }
41
46
 
47
+ const shadowMatch = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
48
+ if (shadowMatch) {
49
+ const [, baseCommit, sessionShort] = shadowMatch;
50
+ if (sessionShort.startsWith(checkpointId) || baseCommit.startsWith(checkpointId)) {
42
51
  return {
43
52
  branch,
44
53
  baseCommit,
@@ -116,11 +125,7 @@ function restoreSessionData(projectRoot, sessionData, newSessionId) {
116
125
  }
117
126
 
118
127
  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}`;
128
+ return randomUUID();
124
129
  }
125
130
 
126
131
  function updateSessionState(sessionDir, checkpoint) {
@@ -149,7 +154,7 @@ function updateSessionState(sessionDir, checkpoint) {
149
154
 
150
155
  export default async function resume(args) {
151
156
  try {
152
- const projectRoot = findProjectRoot();
157
+ const projectRoot = getProjectRoot();
153
158
  const checkpointId = args.find(arg => !arg.startsWith('-'));
154
159
 
155
160
  if (!checkpointId) {
@@ -164,19 +169,19 @@ export default async function resume(args) {
164
169
  const checkpoint = findCheckpoint(projectRoot, checkpointId);
165
170
  if (!checkpoint) {
166
171
  console.error(`❌ Checkpoint not found: ${checkpointId}`);
167
- console.error(' Use "shit shadow" to list available checkpoints');
172
+ console.error(' Use "shit checkpoints" to list available checkpoints');
168
173
  process.exit(1);
169
174
  }
170
175
 
171
176
  console.log(`✅ Found checkpoint: ${checkpoint.sessionShort}`);
172
177
  console.log(` Branch: ${checkpoint.branch}`);
173
- console.log(` Base commit: ${checkpoint.baseCommit}`);
178
+ console.log(` Base commit: ${checkpoint.baseCommit || 'unknown'}`);
174
179
  console.log(` Created: ${new Date(checkpoint.timestamp).toLocaleString()}`);
175
180
  console.log();
176
181
 
177
182
  // Check if we're already at the right commit
178
183
  const currentCommit = git('rev-parse HEAD', projectRoot);
179
- if (!currentCommit.startsWith(checkpoint.baseCommit)) {
184
+ if (checkpoint.baseCommit && !currentCommit.startsWith(checkpoint.baseCommit)) {
180
185
  console.log(`🔄 Checking out base commit: ${checkpoint.baseCommit}`);
181
186
  git(`checkout ${checkpoint.baseCommit}`, projectRoot);
182
187
  }