@claudemini/shit-cli 1.1.0 → 1.2.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/bin/shit.js CHANGED
@@ -4,20 +4,22 @@ const args = process.argv.slice(2);
4
4
  const command = args[0];
5
5
 
6
6
  const commands = {
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',
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
+ commit: 'Create checkpoint on git commit (hook)',
13
+ list: 'List all sessions',
14
+ checkpoints:'List all checkpoints',
15
+ view: 'View session details',
16
+ query: 'Query session memory (cross-session)',
17
+ rewind: 'Rollback to previous checkpoint',
18
+ resume: 'Resume session from checkpoint',
19
+ doctor: 'Fix or clean stuck sessions',
20
+ shadow: 'List shadow branches',
21
+ clean: 'Clean old sessions',
22
+ help: 'Show help',
21
23
  };
22
24
 
23
25
  function showHelp() {
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Checkpoint module - manages checkpoints on git commit
5
+ * Inspired by Entire's approach:
6
+ * - Branch: shit/checkpoints/v1/YYYY-MM-DD-<uuid>
7
+ * - Each checkpoint is a commit linked to a code commit
8
+ * - Supports multiple agents (Claude Code, Gemini CLI, Cursor, etc.)
9
+ */
10
+
11
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'fs';
12
+ import { execSync } from 'child_process';
13
+ import { join, dirname } from 'path';
14
+ import { redactSecrets } from './redact.js';
15
+
16
+ function git(cmd, cwd) {
17
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
18
+ }
19
+
20
+ function gitRaw(cmd, cwd, input) {
21
+ return execSync(`git ${cmd}`, {
22
+ cwd, encoding: 'utf-8', timeout: 10000,
23
+ input,
24
+ }).trim();
25
+ }
26
+
27
+ /**
28
+ * Detect which agent is running based on environment/hooks
29
+ */
30
+ export function detectAgent() {
31
+ // Check for Claude Code
32
+ if (process.env.CLAUDE_AGENT_ID || process.env.CLAUDE_SESSION_ID) {
33
+ return { type: 'claude-code', name: 'Claude Code' };
34
+ }
35
+
36
+ // Check for Gemini CLI
37
+ if (process.env.GEMINI_API_KEY || process.env.GEMINI_CLI) {
38
+ return { type: 'gemini-cli', name: 'Gemini CLI' };
39
+ }
40
+
41
+ // Check for Cursor
42
+ if (process.env.CURSOR_SESSION_ID) {
43
+ return { type: 'cursor', name: 'Cursor' };
44
+ }
45
+
46
+ // Check for OpenCode
47
+ if (process.env.OPENCODE_SESSION_ID) {
48
+ return { type: 'opencode', name: 'OpenCode' };
49
+ }
50
+
51
+ // Check for common hook files
52
+ if (existsSync(join(process.cwd(), '.claude', 'settings.json'))) {
53
+ return { type: 'claude-code', name: 'Claude Code' };
54
+ }
55
+ if (existsSync(join(process.cwd(), '.gemini', 'settings.json'))) {
56
+ return { type: 'gemini-cli', name: 'Gemini CLI' };
57
+ }
58
+ if (existsSync(join(process.cwd(), '.cursor', 'hooks.json'))) {
59
+ return { type: 'cursor', name: 'Cursor' };
60
+ }
61
+
62
+ return { type: 'unknown', name: 'Unknown' };
63
+ }
64
+
65
+ /**
66
+ * Get agent-specific hook configuration
67
+ */
68
+ export function getAgentHooks(agentType) {
69
+ const hooks = {
70
+ 'claude-code': {
71
+ settingsFile: '.claude/settings.json',
72
+ sessionStart: 'SessionStart',
73
+ sessionEnd: 'SessionEnd',
74
+ prompt: 'UserPromptSubmit',
75
+ toolPre: 'PreToolUse',
76
+ toolPost: 'PostToolUse',
77
+ },
78
+ 'gemini-cli': {
79
+ settingsFile: '.gemini/settings.json',
80
+ sessionStart: 'start',
81
+ sessionEnd: 'end',
82
+ prompt: 'prompt',
83
+ toolPre: 'tool_call',
84
+ toolPost: 'tool_result',
85
+ },
86
+ 'cursor': {
87
+ settingsFile: '.cursor/hooks.json',
88
+ sessionStart: 'session_start',
89
+ sessionEnd: 'session_end',
90
+ prompt: 'user_message',
91
+ toolPre: 'tool_before',
92
+ toolPost: 'tool_after',
93
+ },
94
+ 'opencode': {
95
+ settingsFile: '.opencode/plugins/shit.ts',
96
+ sessionStart: 'onSessionStart',
97
+ sessionEnd: 'onSessionEnd',
98
+ prompt: 'onUserMessage',
99
+ toolPre: 'onToolCall',
100
+ toolPost: 'onToolResult',
101
+ },
102
+ };
103
+
104
+ return hooks[agentType] || hooks['claude-code'];
105
+ }
106
+
107
+ /**
108
+ * Build a git tree from a directory with secret redaction
109
+ */
110
+ function buildTree(cwd, dirPath, redact = true) {
111
+ const entries = readdirSync(dirPath);
112
+ const treeLines = [];
113
+
114
+ for (const name of entries) {
115
+ const fullPath = join(dirPath, name);
116
+ const stat = statSync(fullPath);
117
+
118
+ if (stat.isDirectory()) {
119
+ const subTreeSha = buildTree(cwd, fullPath, redact);
120
+ treeLines.push(`040000 tree ${subTreeSha}\t${name}`);
121
+ } else {
122
+ let content = readFileSync(fullPath);
123
+
124
+ // Redact secrets for sensitive files
125
+ if (redact && (name === 'events.jsonl' || name === 'prompts.txt')) {
126
+ content = redactSecrets(content.toString());
127
+ }
128
+
129
+ const blobSha = gitRaw('hash-object -w --stdin', cwd, content);
130
+ treeLines.push(`100644 blob ${blobSha}\t${name}`);
131
+ }
132
+ }
133
+
134
+ if (treeLines.length === 0) return null;
135
+ return gitRaw('mktree', cwd, treeLines.join('\n'));
136
+ }
137
+
138
+ /**
139
+ * Commit session checkpoint to shadow branch
140
+ * Branch format: shit/checkpoints/v1/YYYY-MM-DD-<session-id>
141
+ */
142
+ export async function commitCheckpoint(projectRoot, sessionDir, sessionId, commitSha = null) {
143
+ // Verify we're in a git repo
144
+ try {
145
+ git('rev-parse --git-dir', projectRoot);
146
+ } catch {
147
+ return { success: false, reason: 'not a git repo' };
148
+ }
149
+
150
+ // Branch naming: shit/checkpoints/v1/YYYY-MM-DD-<short-uuid>
151
+ const datePart = sessionId.split('-').slice(0, 3).join('-');
152
+ const uuidPart = sessionId.split('-').slice(3).join('-').slice(0, 8);
153
+ const branchName = `shit/checkpoints/v1/${datePart}-${uuidPart}`;
154
+ const refPath = `refs/heads/${branchName}`;
155
+
156
+ // Get linked commit SHA
157
+ const linkedCommit = commitSha || git('rev-parse HEAD', projectRoot);
158
+ const linkedCommitShort = linkedCommit.slice(0, 12);
159
+
160
+ // Build tree from session directory with redaction
161
+ const sessionTree = buildTree(projectRoot, sessionDir, true);
162
+ if (!sessionTree) {
163
+ return { success: false, reason: 'empty session' };
164
+ }
165
+
166
+ // Wrap session files: .shit-logs/<session-id>/files
167
+ const wrapperLine = `040000 tree ${sessionTree}\t${sessionId}`;
168
+ const logsTree = gitRaw('mktree', projectRoot, wrapperLine);
169
+ const rootLine = `040000 tree ${logsTree}\t.shit-logs`;
170
+ const rootTree = gitRaw('mktree', projectRoot, rootLine);
171
+
172
+ // Check if branch already exists (for parent chaining)
173
+ let parentArg = '';
174
+ try {
175
+ const existing = git(`rev-parse ${refPath}`, projectRoot);
176
+ parentArg = `-p ${existing}`;
177
+ } catch {
178
+ // orphan - no parent
179
+ }
180
+
181
+ // Create commit message with linked commit
182
+ const timestamp = new Date().toISOString().replace('T', ' ').split('.')[0];
183
+ const message = `checkpoint ${sessionId.slice(0, 8)} @ ${linkedCommitShort} | ${timestamp}`;
184
+
185
+ const commitHash = git(
186
+ `commit-tree ${rootTree} ${parentArg} -m "${message}"`,
187
+ projectRoot
188
+ );
189
+
190
+ // Update branch ref
191
+ git(`update-ref ${refPath} ${commitHash}`, projectRoot);
192
+
193
+ // Save checkpoint info to state
194
+ const stateFile = join(sessionDir, 'state.json');
195
+ try {
196
+ let state = JSON.parse(readFileSync(stateFile, 'utf-8'));
197
+ state.checkpoints = state.checkpoints || [];
198
+ state.checkpoints.push({
199
+ branch: branchName,
200
+ commit: commitHash,
201
+ linked_commit: linkedCommit,
202
+ timestamp: new Date().toISOString(),
203
+ });
204
+ writeFileSync(stateFile, JSON.stringify(state, null, 2));
205
+ } catch {
206
+ // best effort
207
+ }
208
+
209
+ return {
210
+ success: true,
211
+ branch: branchName,
212
+ commit: commitHash,
213
+ linked_commit: linkedCommit,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * List all checkpoints in the repo
219
+ */
220
+ export function listCheckpoints(projectRoot) {
221
+ try {
222
+ const output = git('branch --list "shit/checkpoints/v1/*"', projectRoot);
223
+ const branches = output.split('\n').map(b => b.trim().replace(/^\*?\s*/, '')).filter(Boolean);
224
+
225
+ const checkpoints = [];
226
+
227
+ for (const branch of branches) {
228
+ try {
229
+ // Extract session info from branch name
230
+ const match = branch.match(/^shit\/checkpoints\/v1\/(\d{4}-\d{2}-\d{2})-([a-f0-9]+)$/);
231
+ if (match) {
232
+ const [, date, uuidShort] = match;
233
+
234
+ // Get commit info
235
+ const log = git(`log ${branch} --oneline -1`, projectRoot);
236
+ const commitMatch = log.match(/^([a-f0-9]+)\s+checkpoint/);
237
+ const commit = commitMatch ? commitMatch[1] : log.split(' ')[0];
238
+
239
+ // Get linked commit from message
240
+ const fullLog = git(`log ${branch} --format=%B -1`, projectRoot);
241
+ const linkedMatch = fullLog.match(/@ ([a-f0-9]+)/);
242
+ const linkedCommit = linkedMatch ? linkedMatch[1] : null;
243
+
244
+ checkpoints.push({
245
+ branch,
246
+ commit: commit.slice(0, 12),
247
+ linked_commit: linkedCommit,
248
+ date,
249
+ uuid: uuidShort,
250
+ });
251
+ }
252
+ } catch {
253
+ // Skip invalid branches
254
+ }
255
+ }
256
+
257
+ return checkpoints.sort((a, b) => b.date.localeCompare(a.date));
258
+ } catch {
259
+ return [];
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Get checkpoint details
265
+ */
266
+ export function getCheckpoint(projectRoot, branchOrCommit) {
267
+ try {
268
+ // Try as branch first
269
+ let branch = branchOrCommit;
270
+ try {
271
+ git('rev-parse --verify ' + branch, projectRoot);
272
+ } catch {
273
+ // Try as short commit
274
+ try {
275
+ branch = git('rev-parse --verify ' + branchOrCommit + '^{commit}', projectRoot);
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ const log = git(`log ${branch} --format=%B -1`, projectRoot);
282
+ const files = git(`ls-tree -r --name-only ${branch}`, projectRoot);
283
+
284
+ // Extract linked commit
285
+ const linkedMatch = log.match(/@ ([a-f0-9]+)/);
286
+ const linkedCommit = linkedMatch ? linkedMatch[1] : null;
287
+
288
+ return {
289
+ branch,
290
+ commit: branch.slice(0, 12),
291
+ linked_commit: linkedCommit,
292
+ message: log,
293
+ files: files.split('\n').filter(Boolean),
294
+ };
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * List checkpoints - shows all checkpoints created on git commits
5
+ * Inspired by Entire's checkpoint system
6
+ */
7
+
8
+ import { existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { listCheckpoints, getCheckpoint } from './checkpoint.js';
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
+ export default async function checkpoints(args) {
24
+ try {
25
+ const projectRoot = findProjectRoot();
26
+ const verbose = args.includes('--verbose') || args.includes('-v');
27
+ const json = args.includes('--json');
28
+
29
+ const list = listCheckpoints(projectRoot);
30
+
31
+ if (list.length === 0) {
32
+ if (json) {
33
+ console.log(JSON.stringify({ checkpoints: [] }));
34
+ } else {
35
+ console.log('No checkpoints found.');
36
+ console.log('Checkpoints are created when you run "shit commit" after git commit.');
37
+ }
38
+ return;
39
+ }
40
+
41
+ if (json) {
42
+ console.log(JSON.stringify({ checkpoints: list }, null, 2));
43
+ return;
44
+ }
45
+
46
+ console.log(`📸 Found ${list.length} checkpoint(s):\n`);
47
+
48
+ for (const cp of list) {
49
+ console.log(`Branch: ${cp.branch}`);
50
+ console.log(` Commit: ${cp.commit}`);
51
+ console.log(` Linked: ${cp.linked_commit || 'none'}`);
52
+ console.log(` Date: ${cp.date}`);
53
+ console.log();
54
+ }
55
+
56
+ if (verbose) {
57
+ console.log('💡 Usage:');
58
+ console.log(' shit rewind <branch> # Rollback to checkpoint');
59
+ console.log(' shit view <checkpoint> # View checkpoint details');
60
+ }
61
+
62
+ } catch (error) {
63
+ console.error('❌ Failed to list checkpoints:', error.message);
64
+ process.exit(1);
65
+ }
66
+ }
package/lib/commit.js ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Git commit hook - creates checkpoint on every git commit
5
+ * Similar to Entire's approach: checkpoint created on git commit
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { execSync } from 'child_process';
11
+ import { commitCheckpoint } from './checkpoint.js';
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
+ }
23
+
24
+ export default async function commitHook(args) {
25
+ try {
26
+ const projectRoot = findProjectRoot();
27
+
28
+ // Get the commit that was just created
29
+ const commitSha = args[0] || execSync('git rev-parse HEAD', { cwd: projectRoot, encoding: 'utf-8' }).trim();
30
+ const commitShort = commitSha.slice(0, 12);
31
+
32
+ console.log(`📸 Creating checkpoint for commit ${commitShort}...`);
33
+
34
+ // Find active session
35
+ const shitLogsDir = join(projectRoot, '.shit-logs');
36
+ if (!existsSync(shitLogsDir)) {
37
+ console.log('No .shit-logs directory found, skipping checkpoint');
38
+ return;
39
+ }
40
+
41
+ // Find the most recent session
42
+ const { readdirSync, statSync } = await import('fs');
43
+ const sessions = readdirSync(shitLogsDir)
44
+ .filter(name => name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/))
45
+ .map(name => ({
46
+ name,
47
+ path: join(shitLogsDir, name),
48
+ mtime: statSync(join(shitLogsDir, name)).mtime
49
+ }))
50
+ .sort((a, b) => b.mtime - a.mtime);
51
+
52
+ if (sessions.length === 0) {
53
+ console.log('No active session found, skipping checkpoint');
54
+ return;
55
+ }
56
+
57
+ const activeSession = sessions[0];
58
+
59
+ // Create checkpoint
60
+ await commitCheckpoint(projectRoot, activeSession.path, activeSession.name, commitSha);
61
+
62
+ console.log(`✅ Checkpoint created for session ${activeSession.name}`);
63
+
64
+ } catch (error) {
65
+ console.error('Checkpoint creation failed:', error.message);
66
+ // Don't exit with error - checkpoint is best-effort
67
+ }
68
+ }
package/lib/enable.js CHANGED
@@ -3,6 +3,7 @@
3
3
  /**
4
4
  * Enable shit-cli in repository
5
5
  * Similar to 'entire enable' - sets up hooks and configuration
6
+ * Supports multiple agents: Claude Code, Gemini CLI, Cursor, OpenCode
6
7
  */
7
8
 
8
9
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
@@ -20,13 +21,68 @@ function findProjectRoot() {
20
21
  throw new Error('Not in a git repository');
21
22
  }
22
23
 
23
- function setupClaudeHooks(projectRoot) {
24
- const claudeDir = join(projectRoot, '.claude');
25
- const settingsFile = join(claudeDir, 'settings.json');
24
+ // Agent-specific hook configurations
25
+ const AGENT_HOOKS = {
26
+ 'claude-code': {
27
+ dir: '.claude',
28
+ settingsFile: '.claude/settings.json',
29
+ hooks: {
30
+ 'SessionStart': 'shit log session-start',
31
+ 'SessionEnd': 'shit log session-end',
32
+ 'UserPromptSubmit': 'shit log user-prompt-submit',
33
+ 'PreToolUse': 'shit log pre-tool-use',
34
+ 'PostToolUse': 'shit log post-tool-use',
35
+ 'Stop': 'shit log stop',
36
+ 'Notification': 'shit log notification',
37
+ }
38
+ },
39
+ 'gemini-cli': {
40
+ dir: '.gemini',
41
+ settingsFile: '.gemini/settings.json',
42
+ hooks: {
43
+ 'start': 'shit log start',
44
+ 'end': 'shit log end',
45
+ 'prompt': 'shit log prompt',
46
+ 'tool_call': 'shit log tool_call',
47
+ 'tool_result': 'shit log tool_result',
48
+ }
49
+ },
50
+ 'cursor': {
51
+ dir: '.cursor',
52
+ settingsFile: '.cursor/hooks.json',
53
+ hooks: {
54
+ 'session_start': 'shit log session_start',
55
+ 'session_end': 'shit log session_end',
56
+ 'user_message': 'shit log user_message',
57
+ 'tool_before': 'shit log tool_before',
58
+ 'tool_after': 'shit log tool_after',
59
+ }
60
+ },
61
+ 'opencode': {
62
+ dir: '.opencode',
63
+ settingsFile: '.opencode/plugins/shit.ts',
64
+ hooks: {
65
+ 'onSessionStart': 'shit log onSessionStart',
66
+ 'onSessionEnd': 'shit log onSessionEnd',
67
+ 'onUserMessage': 'shit log onUserMessage',
68
+ 'onToolCall': 'shit log onToolCall',
69
+ 'onToolResult': 'shit log onToolResult',
70
+ }
71
+ }
72
+ };
26
73
 
27
- // Create .claude directory if it doesn't exist
28
- if (!existsSync(claudeDir)) {
29
- mkdirSync(claudeDir, { recursive: true });
74
+ function setupAgentHooks(projectRoot, agentType) {
75
+ const config = AGENT_HOOKS[agentType];
76
+ if (!config) {
77
+ throw new Error(`Unknown agent: ${agentType}. Supported: ${Object.keys(AGENT_HOOKS).join(', ')}`);
78
+ }
79
+
80
+ const agentDir = join(projectRoot, config.dir);
81
+ const settingsFile = join(projectRoot, config.settingsFile);
82
+
83
+ // Create agent directory if it doesn't exist
84
+ if (!existsSync(agentDir)) {
85
+ mkdirSync(agentDir, { recursive: true });
30
86
  }
31
87
 
32
88
  let settings = {};
@@ -41,15 +97,52 @@ function setupClaudeHooks(projectRoot) {
41
97
  // Add shit-cli hooks
42
98
  if (!settings.hooks) settings.hooks = {};
43
99
 
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';
100
+ for (const [hookName, command] of Object.entries(config.hooks)) {
101
+ settings.hooks[hookName] = command;
102
+ }
103
+
104
+ // Ensure directory structure for opencode
105
+ if (agentType === 'opencode') {
106
+ const pluginsDir = join(projectRoot, '.opencode', 'plugins');
107
+ if (!existsSync(pluginsDir)) {
108
+ mkdirSync(pluginsDir, { recursive: true });
109
+ }
110
+ }
48
111
 
49
112
  writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
50
113
  return settingsFile;
51
114
  }
52
115
 
116
+ function setupGitCommitHook(projectRoot) {
117
+ // Add a git alias for automatic checkpoint on commit
118
+ const gitConfigFile = join(projectRoot, '.git', 'config');
119
+
120
+ try {
121
+ // Check if alias already exists
122
+ const config = existsSync(gitConfigFile) ? readFileSync(gitConfigFile, 'utf-8') : '';
123
+
124
+ if (!config.includes('shit-commit')) {
125
+ // Create a post-commit hook instead of using alias
126
+ const hooksDir = join(projectRoot, '.git', 'hooks');
127
+ if (!existsSync(hooksDir)) {
128
+ mkdirSync(hooksDir, { recursive: true });
129
+ }
130
+
131
+ const postCommitHook = join(hooksDir, 'post-commit');
132
+ const hookContent = `#!/bin/bash
133
+ # shit-cli: Create checkpoint on git commit
134
+ shit commit $GIT_COMMIT 2>/dev/null || true
135
+ `;
136
+
137
+ writeFileSync(postCommitHook, hookContent);
138
+ execSync(`chmod +x "${postCommitHook}"`, { stdio: 'ignore' });
139
+ console.log('✅ Created post-commit hook for automatic checkpoints');
140
+ }
141
+ } catch {
142
+ // Best effort - git hooks are optional
143
+ }
144
+ }
145
+
53
146
  function initializeShitLogs(projectRoot) {
54
147
  const shitDir = join(projectRoot, '.shit-logs');
55
148
  if (!existsSync(shitDir)) {
@@ -73,11 +166,43 @@ export default async function enable(args) {
73
166
  try {
74
167
  const projectRoot = findProjectRoot();
75
168
 
76
- console.log('🔧 Enabling shit-cli in repository...');
169
+ // Parse arguments
170
+ const agents = [];
171
+ let addCheckpointHook = false;
172
+
173
+ for (const arg of args) {
174
+ if (arg === '--all') {
175
+ // Enable for all supported agents
176
+ agents.push(...Object.keys(AGENT_HOOKS));
177
+ } else if (arg === '--checkpoint' || arg === '-c') {
178
+ addCheckpointHook = true;
179
+ } else if (!arg.startsWith('-')) {
180
+ // Assume it's an agent name
181
+ if (AGENT_HOOKS[arg]) {
182
+ agents.push(arg);
183
+ } else {
184
+ console.log(`⚠️ Unknown agent: ${arg}. Supported: ${Object.keys(AGENT_HOOKS).join(', ')}`);
185
+ }
186
+ }
187
+ }
188
+
189
+ // Default to Claude Code if no agent specified
190
+ if (agents.length === 0) {
191
+ agents.push('claude-code');
192
+ }
193
+
194
+ console.log('🔧 Enabling shit-cli in repository...\n');
77
195
 
78
- // Setup Claude Code hooks
79
- const settingsFile = setupClaudeHooks(projectRoot);
80
- console.log(`✅ Updated Claude hooks in ${settingsFile}`);
196
+ // Setup hooks for each agent
197
+ for (const agent of agents) {
198
+ const settingsFile = setupAgentHooks(projectRoot, agent);
199
+ console.log(`✅ Enabled ${AGENT_HOOKS[agent].dir} hooks: ${settingsFile}`);
200
+ }
201
+
202
+ // Setup git commit hook for checkpoints
203
+ if (addCheckpointHook) {
204
+ setupGitCommitHook(projectRoot);
205
+ }
81
206
 
82
207
  // Initialize .shit-logs directory
83
208
  initializeShitLogs(projectRoot);
@@ -97,10 +222,14 @@ export default async function enable(args) {
97
222
  }
98
223
 
99
224
  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');
225
+ console.log('\nUsage:');
226
+ console.log(' shit status # Show current session');
227
+ console.log(' shit list # List all sessions');
228
+ console.log(' shit checkpoints # List all checkpoints');
229
+ console.log(' shit commit # Manually create checkpoint after git commit');
230
+ console.log('\nOptions:');
231
+ console.log(' --all # Enable for all supported agents');
232
+ console.log(' --checkpoint, -c # Enable automatic checkpoint on git commit');
104
233
 
105
234
  } catch (error) {
106
235
  console.error('❌ Failed to enable shit-cli:', error.message);
package/lib/log.js CHANGED
@@ -50,12 +50,12 @@ export default async function log(args) {
50
50
  const classification = classifySession(intent, changes);
51
51
  generateReports(sessionDir, sessionId, state, intent, changes, classification);
52
52
 
53
- // 4. On session end: shadow branch + update index
53
+ // 4. On session end: checkpoint + update index
54
54
  if (hookType === 'session-end' || hookType === 'stop') {
55
55
  try {
56
- const { commitShadow } = await import('./git-shadow.js');
57
- await commitShadow(projectRoot, sessionDir, sessionId);
58
- } catch { /* shadow is best-effort */ }
56
+ const { commitCheckpoint } = await import('./checkpoint.js');
57
+ await commitCheckpoint(projectRoot, sessionDir, sessionId);
58
+ } catch { /* checkpoint is best-effort */ }
59
59
 
60
60
  try {
61
61
  updateIndex(logDir, sessionId, intent, classification, changes, state);
package/lib/redact.js ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Secrets redaction module
5
+ * Best-effort redaction of sensitive information from logs
6
+ * Reference: Entire's security approach
7
+ */
8
+
9
+ // Common secret patterns
10
+ const SECRET_PATTERNS = [
11
+ // API Keys
12
+ [/(api[_-]?key|apikey|api[_-]?secret)[":\s=]+["']?([a-zA-Z0-9_\-]{20,})["']?/gi, '$1: [REDACTED]'],
13
+
14
+ // AWS
15
+ [/(aws[_-]?access[_-]?key[_-]?id|aws[_-]?secret[_-]?access[_-]?key)[":\s=]+["']?([A-Z0-9]{20,})["']?/gi, '$1: [REDACTED]'],
16
+ [/(AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY)["\s=]+["']?([A-Za-z0-9\/+]{40})["']?/gi, '$1: [REDACTED]'],
17
+
18
+ // GitHub Tokens
19
+ [/(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/gi, '[GITHUB_TOKEN_REDACTED]'],
20
+ [/github[_-]?(token|pat)[":\s=]+["']?[a-zA-Z0-9_]{36,}["']?/gi, 'github_token: [REDACTED]'],
21
+
22
+ // NPM Tokens
23
+ [/(npm|NPM)[_-]?[a-zA-Z0-9]{30,}/gi, '[NPM_TOKEN_REDACTED]'],
24
+ [/(npm[_-]?token|NPM_AUTH_TOKEN)[":\s=]+["']?[a-zA-Z0-9_\-]{30,}["']?/gi, 'npm_token: [REDACTED]'],
25
+
26
+ // OpenAI / Anthropic
27
+ [/(sk-[a-zA-Z0-9]{20,}|sk-ant-[a-zA-Z0-9_\-]{50,})/gi, '[AI_API_KEY_REDACTED]'],
28
+ [/(openai[_-]?key|openai[_-]?token|anthropic[_-]?key)[":\s=]+["']?[a-zA-Z0-9_\-]{20,}["']?/gi, '$1: [REDACTED]'],
29
+
30
+ // Database URLs with credentials
31
+ [/(mysql|postgres|postgresql|mongodb|redis):\/\/[^:]+:[^@]+@/gi, '$1://[REDACTED]@'],
32
+
33
+ // JWT Tokens
34
+ [/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/gi, '[JWT_REDACTED]'],
35
+
36
+ // Generic Bearer tokens
37
+ [/bearer\s+[a-zA-Z0-9_\-\.]{20,}/gi, 'bearer [TOKEN_REDACTED]'],
38
+
39
+ // Private keys
40
+ [/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/gi, '[PRIVATE_KEY_REDACTED]'],
41
+
42
+ // Slack tokens
43
+ [/xox[baprs]-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24,}/gi, '[SLACK_TOKEN_REDACTED]'],
44
+
45
+ // Stripe keys
46
+ [/(sk|pk)_(live|test)_[a-zA-Z0-9]{24,}/gi, '[STRIPE_KEY_REDACTED]'],
47
+
48
+ // Environment variables with secrets
49
+ [/^(AWS_|AZURE_|GOOGLE_|STRIPE_|SENTRY_|DATADOG_)[A-Z_]+=.+$/gim, '$1[REDACTED]'],
50
+
51
+ // Generic "password" fields
52
+ [/"password"\s*:\s*"[^"]+"/gi, '"password": "[REDACTED]"'],
53
+ [/'password'\s*:\s*'[^']+'/gi, "'password': '[REDACTED]'"],
54
+
55
+ // Generic "secret" fields
56
+ [/"secret"\s*:\s*"[^"]+"/gi, '"secret": "[REDACTED]"'],
57
+ [/'secret'\s*:\s*'[^']+'/gi, "'secret': '[REDACTED]'"],
58
+
59
+ // Generic "token" fields
60
+ [/"token"\s*:\s*"[^"]+"/gi, '"token": "[REDACTED]"'],
61
+ [/'token'\s*:\s*'[^']+'/gi, "'token': '[REDACTED]'"],
62
+
63
+ // Authorization headers
64
+ [/authorization:\s*[bB]earer\s+[a-zA-Z0-9_\-\.]{20,}/gi, 'authorization: Bearer [TOKEN_REDACTED]'],
65
+ [/authorization:\s*[bB]asic\s+[a-zA-Z0-9_\-\.]{20,}/gi, 'authorization: Basic [CREDENTIALS_REDACTED]'],
66
+ ];
67
+
68
+ /**
69
+ * Redact secrets from text content
70
+ * @param {string} content - The content to redact
71
+ * @returns {string} - Content with secrets redacted
72
+ */
73
+ export function redactSecrets(content) {
74
+ if (!content || typeof content !== 'string') {
75
+ return content;
76
+ }
77
+
78
+ let redacted = content;
79
+
80
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
81
+ // Reset lastIndex for global patterns
82
+ pattern.lastIndex = 0;
83
+ redacted = redacted.replace(pattern, replacement);
84
+ }
85
+
86
+ return redacted;
87
+ }
88
+
89
+ /**
90
+ * Redact secrets from an object (recursive)
91
+ * @param {object} obj - The object to redact
92
+ * @param {string[]} skipKeys - Keys to skip redaction
93
+ * @returns {object} - Object with secrets redacted
94
+ */
95
+ export function redactObject(obj, skipKeys = ['path', 'filename', 'name']) {
96
+ if (!obj || typeof obj !== 'object') {
97
+ return obj;
98
+ }
99
+
100
+ if (Array.isArray(obj)) {
101
+ return obj.map(item => redactObject(item, skipKeys));
102
+ }
103
+
104
+ const redacted = {};
105
+ const secretKeys = ['password', 'secret', 'token', 'key', 'credential', 'auth', 'authorization', 'api_key', 'apikey'];
106
+
107
+ for (const [key, value] of Object.entries(obj)) {
108
+ // Skip non-secret keys
109
+ const lowerKey = key.toLowerCase();
110
+ if (skipKeys.some(skip => lowerKey.includes(skip.toLowerCase()))) {
111
+ redacted[key] = value;
112
+ continue;
113
+ }
114
+
115
+ // Check if this key might contain a secret
116
+ const isSecretKey = secretKeys.some(secret => lowerKey.includes(secret));
117
+
118
+ if (isSecretKey && typeof value === 'string') {
119
+ redacted[key] = '[REDACTED]';
120
+ } else if (typeof value === 'object' && value !== null) {
121
+ redacted[key] = redactObject(value, skipKeys);
122
+ } else {
123
+ redacted[key] = value;
124
+ }
125
+ }
126
+
127
+ return redacted;
128
+ }
129
+
130
+ /**
131
+ * Redact secrets from a JSON string
132
+ * @param {string} jsonString - The JSON string to redact
133
+ * @returns {string} - JSON with secrets redacted
134
+ */
135
+ export function redactJson(jsonString) {
136
+ try {
137
+ const obj = JSON.parse(jsonString);
138
+ const redacted = redactObject(obj);
139
+ return JSON.stringify(redacted, null, 2);
140
+ } catch {
141
+ // Not valid JSON, try as plain text
142
+ return redactSecrets(jsonString);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Check if content likely contains secrets (for logging)
148
+ * @param {string} content - The content to check
149
+ * @returns {boolean} - True if secrets are likely present
150
+ */
151
+ export function likelyContainsSecrets(content) {
152
+ if (!content || typeof content !== 'string') {
153
+ return false;
154
+ }
155
+
156
+ const secretIndicators = [
157
+ /api[_-]?key/i,
158
+ /password/i,
159
+ /secret/i,
160
+ /token/i,
161
+ /credential/i,
162
+ /authorization/i,
163
+ /private[_-]?key/i,
164
+ /xox[baprs]/,
165
+ /sk-[a-zA-Z0-9]/,
166
+ /eyJ[a-zA-Z0-9_-]*\./,
167
+ ];
168
+
169
+ return secretIndicators.some(pattern => pattern.test(content));
170
+ }
package/lib/report.js CHANGED
@@ -3,7 +3,8 @@
3
3
  /**
4
4
  * Report generation module.
5
5
  * Produces summary.json (v2, bot-readable), summary.txt (human-readable),
6
- * prompts.txt, and metadata.json from session state + semantic data.
6
+ * prompts.txt, context.md, and metadata.json from session state + semantic data.
7
+ * Reference: Entire CLI's checkpoint format
7
8
  */
8
9
 
9
10
  import { writeFileSync } from 'fs';
@@ -21,6 +22,7 @@ export function generateReports(sessionDir, sessionId, state, intent, changes, c
21
22
  writeSummaryTxt(sessionDir, sessionId, state, intent, changes, classification, durationMin);
22
23
  writePromptsTxt(sessionDir, state);
23
24
  writeMetadataJson(sessionDir, sessionId, state, intent, classification, durationMin);
25
+ writeContextMd(sessionDir, sessionId, state, intent, changes, durationMin);
24
26
  }
25
27
 
26
28
  function writeSummaryJson(sessionDir, sessionId, state, intent, changes, classification, durationMin) {
@@ -61,7 +63,9 @@ function writeSummaryJson(sessionDir, sessionId, state, intent, changes, classif
61
63
  config_changed: classification.reviewHints.configChanged,
62
64
  migration_added: classification.reviewHints.migrationAdded,
63
65
  },
64
- prompts: state.prompts.map(p => typeof p === 'string' ? p : p.text),
66
+ prompts: state.prompts.map(p => typeof p === 'string' ? p : { time: p.time, text: p.text, type: p.type }),
67
+ transcript: state.transcript_path,
68
+ model: state.model,
65
69
  scope: intent.scope,
66
70
  };
67
71
  writeFileSync(join(sessionDir, 'summary.json'), JSON.stringify(summary, null, 2));
@@ -181,5 +185,112 @@ function writeMetadataJson(sessionDir, sessionId, state, intent, classification,
181
185
  errors: state.errors.length,
182
186
  scope: intent.scope,
183
187
  last_hook_type: state.last_hook_type,
188
+ // Enhanced metadata for Entire-style tracking
189
+ transcript_path: state.transcript_path || null,
190
+ model: state.model || null,
191
+ cwd: state.cwd || null,
184
192
  }, null, 2));
185
193
  }
194
+
195
+ /**
196
+ * Write context.md - human-readable session context (Entire-style)
197
+ */
198
+ function writeContextMd(sessionDir, sessionId, state, intent, changes, durationMin) {
199
+ const lines = [];
200
+
201
+ // Header
202
+ lines.push(`# Session Context: ${sessionId}`);
203
+ lines.push('');
204
+ lines.push(`**Started**: ${state.start_time ? new Date(state.start_time).toLocaleString() : 'unknown'}`);
205
+ lines.push(`**Ended**: ${state.last_time ? new Date(state.last_time).toLocaleString() : 'in progress'}`);
206
+ lines.push(`**Duration**: ${durationMin} minutes`);
207
+ lines.push(`**Model**: ${state.model || 'unknown'}`);
208
+ lines.push(`**Working Directory**: ${state.cwd || 'unknown'}`);
209
+ lines.push(`**Events**: ${state.event_count}`);
210
+ lines.push('');
211
+
212
+ // Transcript reference (Entire-style)
213
+ if (state.transcript_path) {
214
+ lines.push('## Transcript');
215
+ lines.push(`Full transcript available at: \`${state.transcript_path}\``);
216
+ lines.push('');
217
+ }
218
+
219
+ // User Prompts
220
+ if (state.prompts && state.prompts.length > 0) {
221
+ lines.push('## User Prompts');
222
+ lines.push('');
223
+ state.prompts.forEach((p, i) => {
224
+ const time = p.time ? new Date(p.time).toLocaleString() : '';
225
+ const text = typeof p === 'string' ? p : p.text;
226
+ const type = typeof p === 'object' && p.type ? ` [${p.type}]` : '';
227
+
228
+ lines.push(`### Prompt ${i + 1}${type}${time ? ` - ${time}` : ''}`);
229
+ lines.push('');
230
+ lines.push('```');
231
+ lines.push(text.slice(0, 2000));
232
+ lines.push('```');
233
+ lines.push('');
234
+ });
235
+ }
236
+
237
+ // Intent & Scope
238
+ if (intent.goal) {
239
+ lines.push('## Intent');
240
+ lines.push('');
241
+ lines.push(intent.goal);
242
+ lines.push('');
243
+ }
244
+
245
+ if (intent.scope && intent.scope.length > 0) {
246
+ lines.push('## Scope');
247
+ lines.push('');
248
+ intent.scope.forEach(s => lines.push(`- ${s}`));
249
+ lines.push('');
250
+ }
251
+
252
+ // Changes Summary
253
+ const modified = changes.files.filter(f => f.operations.some(op => op !== 'read'));
254
+ if (modified.length > 0) {
255
+ lines.push('## Files Changed');
256
+ lines.push('');
257
+ modified.slice(0, 30).forEach(f => {
258
+ const ops = f.operations.filter(op => op !== 'read').join(', ');
259
+ lines.push(`- \`${f.path}\` (${ops})`);
260
+ });
261
+ if (modified.length > 30) {
262
+ lines.push(`- ... and ${modified.length - 30} more files`);
263
+ }
264
+ lines.push('');
265
+ }
266
+
267
+ // Tool Usage
268
+ if (Object.keys(state.tool_counts).length > 0) {
269
+ lines.push('## Tool Usage');
270
+ lines.push('');
271
+ Object.entries(state.tool_counts)
272
+ .sort((a, b) => b[1] - a[1])
273
+ .forEach(([tool, count]) => {
274
+ lines.push(`- ${tool}: ${count}`);
275
+ });
276
+ lines.push('');
277
+ }
278
+
279
+ // Errors
280
+ if (state.errors.length > 0) {
281
+ lines.push('## Errors');
282
+ lines.push('');
283
+ state.errors.slice(0, 10).forEach(e => {
284
+ lines.push(`- **[${e.tool}]** ${(e.message || '').slice(0, 200)}`);
285
+ });
286
+ lines.push('');
287
+ }
288
+
289
+ // Footer
290
+ lines.push('---');
291
+ lines.push('');
292
+ lines.push(`*Generated by shit-cli at ${new Date().toISOString()}*`);
293
+ lines.push(`*See \`events.jsonl\` for complete event history*`);
294
+
295
+ writeFileSync(join(sessionDir, 'context.md'), lines.join('\n') + '\n');
296
+ }
package/lib/session.js CHANGED
@@ -22,6 +22,10 @@ const EMPTY_STATE = {
22
22
  prompts: [],
23
23
  last_hook_type: null,
24
24
  shadow_branch: null,
25
+ // Enhanced fields for Entire-style tracking
26
+ transcript_path: null,
27
+ cwd: null,
28
+ model: null,
25
29
  };
26
30
 
27
31
  export function loadState(sessionDir) {
@@ -63,10 +67,41 @@ export function processEvent(state, event, hookType, projectRoot) {
63
67
  const toolName = event.tool_name;
64
68
  const input = event.tool_input || {};
65
69
 
66
- // User prompts
67
- if (hookType === 'user-prompt-submit') {
68
- const prompt = event.prompt || event.message || '';
69
- if (prompt) state.prompts.push({ time: now, text: prompt });
70
+ // User prompts - support both camelCase and kebab-case
71
+ if (hookType === 'user-prompt-submit' || hookType === 'UserPromptSubmit') {
72
+ // Extract prompt from various possible fields
73
+ let prompt = '';
74
+ if (event.prompt) {
75
+ prompt = typeof event.prompt === 'string' ? event.prompt :
76
+ event.prompt.text || event.prompt.message || JSON.stringify(event.prompt).slice(0, 500);
77
+ } else if (event.message) {
78
+ prompt = typeof event.message === 'string' ? event.message :
79
+ event.message.text || JSON.stringify(event.message).slice(0, 500);
80
+ } else if (event.text) {
81
+ prompt = event.text;
82
+ }
83
+
84
+ if (prompt) {
85
+ state.prompts.push({
86
+ time: now,
87
+ text: prompt.slice(0, 2000), // Limit prompt length
88
+ type: event.prompt_type || 'user'
89
+ });
90
+ }
91
+ return;
92
+ }
93
+
94
+ // Record transcript path from SessionStart
95
+ if (hookType === 'session-start' || hookType === 'SessionStart') {
96
+ if (event.transcript_path) {
97
+ state.transcript_path = event.transcript_path;
98
+ }
99
+ if (event.cwd) {
100
+ state.cwd = event.cwd;
101
+ }
102
+ if (event.model) {
103
+ state.model = event.model;
104
+ }
70
105
  return;
71
106
  }
72
107
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudemini/shit-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Session-based Hook Intelligence Tracker - CLI tool for logging Claude Code hooks",
5
5
  "type": "module",
6
6
  "bin": {