@claudemini/ses-cli 1.4.3
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/README.md +465 -0
- package/bin/ses.js +85 -0
- package/lib/agent-review.js +722 -0
- package/lib/checkpoint.js +320 -0
- package/lib/checkpoints.js +54 -0
- package/lib/clean.js +45 -0
- package/lib/commit.js +60 -0
- package/lib/config.js +28 -0
- package/lib/disable.js +152 -0
- package/lib/doctor.js +307 -0
- package/lib/enable.js +294 -0
- package/lib/explain.js +212 -0
- package/lib/extract.js +265 -0
- package/lib/git-shadow.js +136 -0
- package/lib/init.js +83 -0
- package/lib/list.js +62 -0
- package/lib/log.js +77 -0
- package/lib/prompts.js +125 -0
- package/lib/query.js +110 -0
- package/lib/redact.js +170 -0
- package/lib/report.js +296 -0
- package/lib/reset.js +122 -0
- package/lib/resume.js +224 -0
- package/lib/review-common.js +100 -0
- package/lib/review.js +652 -0
- package/lib/rewind.js +198 -0
- package/lib/session.js +225 -0
- package/lib/shadow.js +51 -0
- package/lib/status.js +198 -0
- package/lib/summarize.js +315 -0
- package/lib/view.js +50 -0
- package/lib/webhook.js +224 -0
- package/package.json +41 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checkpoint module - manages checkpoints on git commit
|
|
5
|
+
* Inspired by Entire's approach:
|
|
6
|
+
* - Branch: ses/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 { existsSync, 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/ses.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: ses/checkpoints/v1/YYYY-MM-DD-<session-id>
|
|
141
|
+
*/
|
|
142
|
+
export async function commitCheckpoint(projectRoot, sessionDir, sessionId, commitSha = null, options = {}) {
|
|
143
|
+
const autoSummarize = options.autoSummarize !== false; // default true
|
|
144
|
+
|
|
145
|
+
// Verify we're in a git repo
|
|
146
|
+
try {
|
|
147
|
+
git('rev-parse --git-dir', projectRoot);
|
|
148
|
+
} catch {
|
|
149
|
+
return { success: false, reason: 'not a git repo' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Branch naming: ses/checkpoints/v1/YYYY-MM-DD-<session-short>
|
|
153
|
+
const datePart = new Date().toISOString().slice(0, 10);
|
|
154
|
+
const sessionCompact = sessionId.toLowerCase().replace(/[^a-f0-9]/g, '');
|
|
155
|
+
const uuidPart = (sessionCompact.slice(-8) || sessionCompact.slice(0, 8) || 'unknown00').padEnd(8, '0');
|
|
156
|
+
const branchName = `ses/checkpoints/v1/${datePart}-${uuidPart}`;
|
|
157
|
+
const refPath = `refs/heads/${branchName}`;
|
|
158
|
+
|
|
159
|
+
// Get linked commit SHA
|
|
160
|
+
const linkedCommit = commitSha || git('rev-parse HEAD', projectRoot);
|
|
161
|
+
const linkedCommitShort = linkedCommit.slice(0, 12);
|
|
162
|
+
|
|
163
|
+
// Build tree from session directory with redaction
|
|
164
|
+
const sessionTree = buildTree(projectRoot, sessionDir, true);
|
|
165
|
+
if (!sessionTree) {
|
|
166
|
+
return { success: false, reason: 'empty session' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Wrap session files: .ses-logs/<session-id>/files
|
|
170
|
+
const wrapperLine = `040000 tree ${sessionTree}\t${sessionId}`;
|
|
171
|
+
const logsTree = gitRaw('mktree', projectRoot, wrapperLine);
|
|
172
|
+
const rootLine = `040000 tree ${logsTree}\t.ses-logs`;
|
|
173
|
+
const rootTree = gitRaw('mktree', projectRoot, rootLine);
|
|
174
|
+
|
|
175
|
+
// Check if branch already exists (for parent chaining)
|
|
176
|
+
let parentArg = '';
|
|
177
|
+
try {
|
|
178
|
+
const existing = git(`rev-parse ${refPath}`, projectRoot);
|
|
179
|
+
parentArg = `-p ${existing}`;
|
|
180
|
+
} catch {
|
|
181
|
+
// orphan - no parent
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Create commit message with linked commit
|
|
185
|
+
const timestamp = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
186
|
+
const message = `checkpoint ${sessionId.slice(0, 8)} @ ${linkedCommitShort} | ${timestamp}`;
|
|
187
|
+
|
|
188
|
+
const commitHash = git(
|
|
189
|
+
`commit-tree ${rootTree} ${parentArg} -m "${message}"`,
|
|
190
|
+
projectRoot
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Update branch ref
|
|
194
|
+
git(`update-ref ${refPath} ${commitHash}`, projectRoot);
|
|
195
|
+
|
|
196
|
+
// Save checkpoint info to state
|
|
197
|
+
const stateFile = join(sessionDir, 'state.json');
|
|
198
|
+
try {
|
|
199
|
+
let state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
200
|
+
state.checkpoints = state.checkpoints || [];
|
|
201
|
+
state.checkpoints.push({
|
|
202
|
+
branch: branchName,
|
|
203
|
+
commit: commitHash,
|
|
204
|
+
linked_commit: linkedCommit,
|
|
205
|
+
timestamp: new Date().toISOString(),
|
|
206
|
+
});
|
|
207
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
208
|
+
} catch {
|
|
209
|
+
// best effort
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Auto-summarize if enabled
|
|
213
|
+
if (autoSummarize) {
|
|
214
|
+
try {
|
|
215
|
+
const { summarizeSession } = await import('./summarize.js');
|
|
216
|
+
const summaryResult = await summarizeSession(projectRoot, sessionId, sessionDir);
|
|
217
|
+
if (summaryResult.success) {
|
|
218
|
+
console.log(`ā
AI summary generated: ${summaryResult.model}`);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// Best effort - summarize is optional
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
success: true,
|
|
227
|
+
branch: branchName,
|
|
228
|
+
commit: commitHash,
|
|
229
|
+
linked_commit: linkedCommit,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* List all checkpoints in the repo
|
|
235
|
+
*/
|
|
236
|
+
export function listCheckpoints(projectRoot) {
|
|
237
|
+
try {
|
|
238
|
+
const output = git('branch --list "ses/checkpoints/v1/*"', projectRoot);
|
|
239
|
+
const branches = output.split('\n').map(b => b.trim().replace(/^\*?\s*/, '')).filter(Boolean);
|
|
240
|
+
|
|
241
|
+
const checkpoints = [];
|
|
242
|
+
|
|
243
|
+
for (const branch of branches) {
|
|
244
|
+
try {
|
|
245
|
+
// Extract session info from branch name (supports current and legacy formats)
|
|
246
|
+
const match = branch.match(/^(?:ses|shit)\/checkpoints\/v1\/(\d{4}-\d{2}-\d{2})-([a-f0-9]+)$/);
|
|
247
|
+
const legacyMatch = branch.match(/^(?:ses|shit)\/checkpoints\/v1\/([a-f0-9-]+)$/);
|
|
248
|
+
if (!match && !legacyMatch) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const date = match ? match[1] : git(`log ${branch} --format=%cs -1`, projectRoot);
|
|
253
|
+
const uuidShort = match
|
|
254
|
+
? match[2]
|
|
255
|
+
: ((legacyMatch[1].replace(/[^a-f0-9]/g, '').slice(-8) || legacyMatch[1].slice(0, 8)).toLowerCase());
|
|
256
|
+
|
|
257
|
+
// Get commit info
|
|
258
|
+
const log = git(`log ${branch} --oneline -1`, projectRoot);
|
|
259
|
+
const commitMatch = log.match(/^([a-f0-9]+)\s+checkpoint/);
|
|
260
|
+
const commit = commitMatch ? commitMatch[1] : log.split(' ')[0];
|
|
261
|
+
|
|
262
|
+
// Get linked commit from message
|
|
263
|
+
const fullLog = git(`log ${branch} --format=%B -1`, projectRoot);
|
|
264
|
+
const linkedMatch = fullLog.match(/@ ([a-f0-9]+)/);
|
|
265
|
+
const linkedCommit = linkedMatch ? linkedMatch[1] : null;
|
|
266
|
+
|
|
267
|
+
checkpoints.push({
|
|
268
|
+
branch,
|
|
269
|
+
commit: commit.slice(0, 12),
|
|
270
|
+
linked_commit: linkedCommit,
|
|
271
|
+
date,
|
|
272
|
+
uuid: uuidShort,
|
|
273
|
+
});
|
|
274
|
+
} catch {
|
|
275
|
+
// Skip invalid branches
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return checkpoints.sort((a, b) => b.date.localeCompare(a.date));
|
|
280
|
+
} catch {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get checkpoint details
|
|
287
|
+
*/
|
|
288
|
+
export function getCheckpoint(projectRoot, branchOrCommit) {
|
|
289
|
+
try {
|
|
290
|
+
// Try as branch first
|
|
291
|
+
let branch = branchOrCommit;
|
|
292
|
+
try {
|
|
293
|
+
git('rev-parse --verify ' + branch, projectRoot);
|
|
294
|
+
} catch {
|
|
295
|
+
// Try as short commit
|
|
296
|
+
try {
|
|
297
|
+
branch = git('rev-parse --verify ' + branchOrCommit + '^{commit}', projectRoot);
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const log = git(`log ${branch} --format=%B -1`, projectRoot);
|
|
304
|
+
const files = git(`ls-tree -r --name-only ${branch}`, projectRoot);
|
|
305
|
+
|
|
306
|
+
// Extract linked commit
|
|
307
|
+
const linkedMatch = log.match(/@ ([a-f0-9]+)/);
|
|
308
|
+
const linkedCommit = linkedMatch ? linkedMatch[1] : null;
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
branch,
|
|
312
|
+
commit: branch.slice(0, 12),
|
|
313
|
+
linked_commit: linkedCommit,
|
|
314
|
+
message: log,
|
|
315
|
+
files: files.split('\n').filter(Boolean),
|
|
316
|
+
};
|
|
317
|
+
} catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { listCheckpoints } from './checkpoint.js';
|
|
9
|
+
import { getProjectRoot } from './config.js';
|
|
10
|
+
|
|
11
|
+
export default async function checkpoints(args) {
|
|
12
|
+
try {
|
|
13
|
+
const projectRoot = getProjectRoot();
|
|
14
|
+
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
15
|
+
const json = args.includes('--json');
|
|
16
|
+
|
|
17
|
+
const list = listCheckpoints(projectRoot);
|
|
18
|
+
|
|
19
|
+
if (list.length === 0) {
|
|
20
|
+
if (json) {
|
|
21
|
+
console.log(JSON.stringify({ checkpoints: [] }));
|
|
22
|
+
} else {
|
|
23
|
+
console.log('No checkpoints found.');
|
|
24
|
+
console.log('Checkpoints are created when you run "ses commit" after git commit.');
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (json) {
|
|
30
|
+
console.log(JSON.stringify({ checkpoints: list }, null, 2));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(`šø Found ${list.length} checkpoint(s):\n`);
|
|
35
|
+
|
|
36
|
+
for (const cp of list) {
|
|
37
|
+
console.log(`Branch: ${cp.branch}`);
|
|
38
|
+
console.log(` Commit: ${cp.commit}`);
|
|
39
|
+
console.log(` Linked: ${cp.linked_commit || 'none'}`);
|
|
40
|
+
console.log(` Date: ${cp.date}`);
|
|
41
|
+
console.log();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (verbose) {
|
|
45
|
+
console.log('š” Usage:');
|
|
46
|
+
console.log(' ses rewind <branch> # Rollback to checkpoint');
|
|
47
|
+
console.log(' ses view <checkpoint> # View checkpoint details');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('ā Failed to list checkpoints:', error.message);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/lib/clean.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readdirSync, statSync, rmSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { getProjectRoot, getLogDir, SESSION_ID_REGEX } from './config.js';
|
|
6
|
+
|
|
7
|
+
export default async function clean(args) {
|
|
8
|
+
const logDir = getLogDir(getProjectRoot());
|
|
9
|
+
const daysArg = args.find(a => a.startsWith('--days='));
|
|
10
|
+
const days = daysArg ? parseInt(daysArg.split('=')[1]) : 7;
|
|
11
|
+
const dryRun = args.includes('--dry-run');
|
|
12
|
+
const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const entries = readdirSync(logDir);
|
|
16
|
+
const old = entries
|
|
17
|
+
.filter(name => SESSION_ID_REGEX.test(name))
|
|
18
|
+
.map(id => {
|
|
19
|
+
const dir = join(logDir, id);
|
|
20
|
+
return { id, dir, mtime: statSync(dir).mtime.getTime() };
|
|
21
|
+
})
|
|
22
|
+
.filter(s => s.mtime < cutoffTime);
|
|
23
|
+
|
|
24
|
+
if (old.length === 0) {
|
|
25
|
+
console.log(`No sessions older than ${days} days.`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(`${old.length} session(s) older than ${days} days:`);
|
|
30
|
+
old.forEach(s => {
|
|
31
|
+
const age = Math.floor((Date.now() - s.mtime) / 86400000);
|
|
32
|
+
console.log(` ${s.id.slice(0, 8)}... (${age}d)`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (dryRun) {
|
|
36
|
+
console.log('\n[DRY RUN] No files deleted.');
|
|
37
|
+
} else {
|
|
38
|
+
old.forEach(s => rmSync(s.dir, { recursive: true, force: true }));
|
|
39
|
+
console.log(`\nDeleted ${old.length} session(s).`);
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === 'ENOENT') console.log('No log directory found.');
|
|
43
|
+
else throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/lib/commit.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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, readdirSync, statSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
import { commitCheckpoint } from './checkpoint.js';
|
|
12
|
+
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
|
|
13
|
+
|
|
14
|
+
export default async function commitHook(args) {
|
|
15
|
+
try {
|
|
16
|
+
const projectRoot = getProjectRoot();
|
|
17
|
+
|
|
18
|
+
// Get the commit that was just created
|
|
19
|
+
const commitSha = args[0] || execSync('git rev-parse HEAD', { cwd: projectRoot, encoding: 'utf-8' }).trim();
|
|
20
|
+
const commitShort = commitSha.slice(0, 12);
|
|
21
|
+
|
|
22
|
+
console.log(`šø Creating checkpoint for commit ${commitShort}...`);
|
|
23
|
+
|
|
24
|
+
// Find active session
|
|
25
|
+
const sesLogsDir = join(projectRoot, '.ses-logs');
|
|
26
|
+
if (!existsSync(sesLogsDir)) {
|
|
27
|
+
console.log('No .ses-logs directory found, skipping checkpoint');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Find the most recent session
|
|
32
|
+
const sessions = readdirSync(sesLogsDir)
|
|
33
|
+
.filter(name => {
|
|
34
|
+
const fullPath = join(sesLogsDir, name);
|
|
35
|
+
return SESSION_ID_REGEX.test(name) && statSync(fullPath).isDirectory();
|
|
36
|
+
})
|
|
37
|
+
.map(name => ({
|
|
38
|
+
name,
|
|
39
|
+
path: join(sesLogsDir, name),
|
|
40
|
+
mtime: statSync(join(sesLogsDir, name)).mtime
|
|
41
|
+
}))
|
|
42
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
43
|
+
|
|
44
|
+
if (sessions.length === 0) {
|
|
45
|
+
console.log('No active session found, skipping checkpoint');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const activeSession = sessions[0];
|
|
50
|
+
|
|
51
|
+
// Create checkpoint
|
|
52
|
+
await commitCheckpoint(projectRoot, activeSession.path, activeSession.name, commitSha);
|
|
53
|
+
|
|
54
|
+
console.log(`ā
Checkpoint created for session ${activeSession.name}`);
|
|
55
|
+
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('Checkpoint creation failed:', error.message);
|
|
58
|
+
// Don't exit with error - checkpoint is best-effort
|
|
59
|
+
}
|
|
60
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
export const UUID_SESSION_ID_REGEX = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
7
|
+
export const LEGACY_SESSION_ID_REGEX = /^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/;
|
|
8
|
+
export const SESSION_ID_REGEX = /^(?:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|\d{4}-\d{2}-\d{2}-[a-f0-9-]+)$/;
|
|
9
|
+
|
|
10
|
+
export function getProjectRoot() {
|
|
11
|
+
try {
|
|
12
|
+
return execSync('git rev-parse --show-toplevel', {
|
|
13
|
+
encoding: 'utf-8',
|
|
14
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
15
|
+
}).trim();
|
|
16
|
+
} catch {
|
|
17
|
+
return process.cwd();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getLogDir(projectRoot) {
|
|
22
|
+
return process.env.SES_LOG_DIR || join(projectRoot || getProjectRoot(), '.ses-logs');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toRelative(projectRoot, filePath) {
|
|
26
|
+
if (!filePath || !filePath.startsWith('/')) return filePath;
|
|
27
|
+
return relative(projectRoot, filePath) || filePath;
|
|
28
|
+
}
|
package/lib/disable.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Disable ses-cli in repository
|
|
5
|
+
* Similar to 'entire disable' - removes hooks and optionally cleans data
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { getProjectRoot } from './config.js';
|
|
11
|
+
|
|
12
|
+
const CLAUDE_HOOK_TYPES = [
|
|
13
|
+
'SessionStart',
|
|
14
|
+
'SessionEnd',
|
|
15
|
+
'UserPromptSubmit',
|
|
16
|
+
'PreToolUse',
|
|
17
|
+
'PostToolUse',
|
|
18
|
+
'Stop',
|
|
19
|
+
'Notification',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function removeClaudeHooks(projectRoot) {
|
|
23
|
+
const settingsFile = join(projectRoot, '.claude', 'settings.json');
|
|
24
|
+
|
|
25
|
+
if (!existsSync(settingsFile)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const settings = JSON.parse(readFileSync(settingsFile, 'utf-8'));
|
|
31
|
+
let removed = false;
|
|
32
|
+
|
|
33
|
+
if (settings.hooks) {
|
|
34
|
+
for (const hookType of CLAUDE_HOOK_TYPES) {
|
|
35
|
+
const rawEntries = settings.hooks[hookType];
|
|
36
|
+
if (Array.isArray(rawEntries)) {
|
|
37
|
+
const nextEntries = rawEntries
|
|
38
|
+
.map(entry => {
|
|
39
|
+
if (!Array.isArray(entry?.hooks)) {
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
const nextHooks = entry.hooks.filter(hook =>
|
|
43
|
+
!(typeof hook?.command === 'string' && hook.command.includes('ses log'))
|
|
44
|
+
);
|
|
45
|
+
if (nextHooks.length !== entry.hooks.length) {
|
|
46
|
+
removed = true;
|
|
47
|
+
}
|
|
48
|
+
if (nextHooks.length === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return { ...entry, hooks: nextHooks };
|
|
52
|
+
})
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
|
|
55
|
+
if (nextEntries.length > 0) {
|
|
56
|
+
settings.hooks[hookType] = nextEntries;
|
|
57
|
+
} else {
|
|
58
|
+
delete settings.hooks[hookType];
|
|
59
|
+
}
|
|
60
|
+
} else if (typeof rawEntries === 'string' && rawEntries.includes('ses log')) {
|
|
61
|
+
delete settings.hooks[hookType];
|
|
62
|
+
removed = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Cleanup legacy wrong names written by old versions.
|
|
67
|
+
const legacyKeys = ['session_start', 'session_end', 'tool_use', 'edit_applied'];
|
|
68
|
+
for (const key of legacyKeys) {
|
|
69
|
+
if (Object.prototype.hasOwnProperty.call(settings.hooks, key)) {
|
|
70
|
+
delete settings.hooks[key];
|
|
71
|
+
removed = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// If hooks object is empty, remove it
|
|
76
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
77
|
+
delete settings.hooks;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
|
|
82
|
+
return removed;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function removeFromGitignore(projectRoot) {
|
|
89
|
+
const gitignoreFile = join(projectRoot, '.gitignore');
|
|
90
|
+
|
|
91
|
+
if (!existsSync(gitignoreFile)) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
let gitignore = readFileSync(gitignoreFile, 'utf-8');
|
|
97
|
+
const lines = gitignore.split('\n');
|
|
98
|
+
const filtered = lines.filter(line => !line.trim().startsWith('.ses-logs'));
|
|
99
|
+
|
|
100
|
+
if (filtered.length !== lines.length) {
|
|
101
|
+
writeFileSync(gitignoreFile, filtered.join('\n'));
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Ignore errors
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export default async function disable(args) {
|
|
112
|
+
try {
|
|
113
|
+
const projectRoot = getProjectRoot();
|
|
114
|
+
const cleanData = args.includes('--clean') || args.includes('--purge');
|
|
115
|
+
|
|
116
|
+
console.log('š§ Disabling ses-cli in repository...');
|
|
117
|
+
|
|
118
|
+
// Remove Claude Code hooks
|
|
119
|
+
const hooksRemoved = removeClaudeHooks(projectRoot);
|
|
120
|
+
if (hooksRemoved) {
|
|
121
|
+
console.log('ā
Removed Claude hooks from .claude/settings.json');
|
|
122
|
+
} else {
|
|
123
|
+
console.log('ā¹ļø No Claude hooks found to remove');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Remove from .gitignore
|
|
127
|
+
const gitignoreUpdated = removeFromGitignore(projectRoot);
|
|
128
|
+
if (gitignoreUpdated) {
|
|
129
|
+
console.log('ā
Removed .ses-logs from .gitignore');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Optionally clean data
|
|
133
|
+
if (cleanData) {
|
|
134
|
+
const sesLogsDir = join(projectRoot, '.ses-logs');
|
|
135
|
+
if (existsSync(sesLogsDir)) {
|
|
136
|
+
rmSync(sesLogsDir, { recursive: true, force: true });
|
|
137
|
+
console.log('ā
Removed .ses-logs directory');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log('\nš ses-cli disabled successfully!');
|
|
142
|
+
|
|
143
|
+
if (!cleanData) {
|
|
144
|
+
console.log('\nNote: .ses-logs directory preserved.');
|
|
145
|
+
console.log('Use "ses disable --clean" to remove all data.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error('ā Failed to disable ses-cli:', error.message);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
}
|