@claudemini/shit-cli 1.1.1 → 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 +16 -14
- package/lib/checkpoint.js +298 -0
- package/lib/checkpoints.js +66 -0
- package/lib/commit.js +68 -0
- package/lib/enable.js +147 -18
- package/lib/log.js +4 -4
- package/lib/redact.js +170 -0
- package/package.json +1 -1
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:
|
|
8
|
-
disable:
|
|
9
|
-
status:
|
|
10
|
-
init:
|
|
11
|
-
log:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
const
|
|
80
|
-
|
|
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('\
|
|
101
|
-
console.log('
|
|
102
|
-
console.log('
|
|
103
|
-
console.log('
|
|
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:
|
|
53
|
+
// 4. On session end: checkpoint + update index
|
|
54
54
|
if (hookType === 'session-end' || hookType === 'stop') {
|
|
55
55
|
try {
|
|
56
|
-
const {
|
|
57
|
-
await
|
|
58
|
-
} catch { /*
|
|
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
|
+
}
|