@claudemini/shit-cli 1.0.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/bin/shit.js ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+
3
+ const args = process.argv.slice(2);
4
+ const command = args[0];
5
+
6
+ const commands = {
7
+ init: 'Initialize hooks in .claude/settings.json',
8
+ log: 'Log a hook event (called by hooks)',
9
+ list: 'List all sessions',
10
+ view: 'View session details',
11
+ query: 'Query session memory (cross-session)',
12
+ shadow: 'List shadow branches',
13
+ clean: 'Clean old sessions',
14
+ help: 'Show help',
15
+ };
16
+
17
+ function showHelp() {
18
+ console.log('shit-cli - Session Hook Intelligence Tracker\n');
19
+ console.log('Usage: shit <command> [options]\n');
20
+ console.log('Commands:');
21
+ Object.entries(commands).forEach(([cmd, desc]) => {
22
+ console.log(` ${cmd.padEnd(10)} ${desc}`);
23
+ });
24
+ console.log('\nExamples:');
25
+ console.log(' shit init # Register hooks');
26
+ console.log(' shit list # List sessions');
27
+ console.log(' shit view <session-id> # View session');
28
+ console.log(' shit view <session-id> --json # View with JSON data');
29
+ console.log(' shit query --recent=5 # Recent 5 sessions');
30
+ console.log(' shit query --file=src/app.ts # Sessions that touched file');
31
+ console.log(' shit query --type=bugfix # Filter by session type');
32
+ console.log(' shit query --risk=high --json # High-risk sessions as JSON');
33
+ console.log(' shit shadow # List shadow branches');
34
+ console.log(' shit clean --days=7 # Clean old sessions');
35
+ }
36
+
37
+ if (!command || command === 'help' || command === '--help') {
38
+ showHelp();
39
+ process.exit(0);
40
+ }
41
+
42
+ if (command === '--version' || command === '-v') {
43
+ const { readFileSync } = await import('fs');
44
+ const { fileURLToPath } = await import('url');
45
+ const { dirname, join } = await import('path');
46
+ const __dirname = dirname(fileURLToPath(import.meta.url));
47
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
48
+ console.log(`shit-cli v${pkg.version}`);
49
+ process.exit(0);
50
+ }
51
+
52
+ try {
53
+ const mod = await import(`../lib/${command}.js`);
54
+ await mod.default(args.slice(1));
55
+ } catch (error) {
56
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
57
+ console.error(`Unknown command: ${command}`);
58
+ process.exit(1);
59
+ }
60
+ throw error;
61
+ }
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/config.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { join, relative } from 'path';
4
+ import { execSync } from 'child_process';
5
+
6
+ export const SESSION_ID_REGEX = /^[a-f0-9-]{36}$/;
7
+
8
+ export function getProjectRoot() {
9
+ try {
10
+ return execSync('git rev-parse --show-toplevel', {
11
+ encoding: 'utf-8',
12
+ stdio: ['pipe', 'pipe', 'pipe'],
13
+ }).trim();
14
+ } catch {
15
+ return process.cwd();
16
+ }
17
+ }
18
+
19
+ export function getLogDir(projectRoot) {
20
+ return process.env.SHIT_LOG_DIR || join(projectRoot || getProjectRoot(), '.shit-logs');
21
+ }
22
+
23
+ export function toRelative(projectRoot, filePath) {
24
+ if (!filePath || !filePath.startsWith('/')) return filePath;
25
+ return relative(projectRoot, filePath) || filePath;
26
+ }
package/lib/extract.js ADDED
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Semantic extraction engine.
5
+ * Transforms raw hook events into structured, meaningful data
6
+ * for both humans and code review bots.
7
+ */
8
+
9
+ // --- Intent extraction from user prompts ---
10
+
11
+ const INTENT_PATTERNS = [
12
+ { pattern: /\b(fix|bug|error|crash|broken|issue|wrong|fail)\b/i, type: 'bugfix' },
13
+ { pattern: /\b(add|create|implement|new|feature|introduce)\b/i, type: 'feature' },
14
+ { pattern: /\b(refactor|clean|reorganize|restructure|extract|move|rename|simplify)\b/i, type: 'refactor' },
15
+ { pattern: /\b(debug|investigate|trace|log|print|inspect|check)\b/i, type: 'debug' },
16
+ { pattern: /\b(test|spec|coverage|assert)\b/i, type: 'test' },
17
+ { pattern: /\b(doc|readme|comment|explain|document)\b/i, type: 'docs' },
18
+ { pattern: /\b(deploy|release|publish|build|ci|cd|pipeline)\b/i, type: 'devops' },
19
+ { pattern: /\b(upgrade|update|bump|migrate|version)\b/i, type: 'upgrade' },
20
+ { pattern: /\b(config|setting|env|environment|setup)\b/i, type: 'config' },
21
+ { pattern: /\b(style|format|lint|prettier|css|ui|layout)\b/i, type: 'style' },
22
+ { pattern: /\b(security|auth|permission|token|secret|vulnerability)\b/i, type: 'security' },
23
+ { pattern: /\b(perf|performance|optimize|speed|slow|fast|cache)\b/i, type: 'perf' },
24
+ ];
25
+
26
+ const SCOPE_PATTERNS = [
27
+ { pattern: /\b(auth|login|token|jwt|session|password)\b/i, scope: 'auth' },
28
+ { pattern: /\b(api|endpoint|route|controller|handler)\b/i, scope: 'api' },
29
+ { pattern: /\b(database|db|prisma|migration|schema|sql|query)\b/i, scope: 'database' },
30
+ { pattern: /\b(test|spec|jest|vitest)\b/i, scope: 'testing' },
31
+ { pattern: /\b(ui|component|page|view|template|frontend)\b/i, scope: 'ui' },
32
+ { pattern: /\b(deploy|docker|ci|pipeline|k8s|server)\b/i, scope: 'infra' },
33
+ { pattern: /\b(config|env|setting)\b/i, scope: 'config' },
34
+ { pattern: /\b(webhook|hook|event)\b/i, scope: 'webhook' },
35
+ { pattern: /\b(agent|bot|worker)\b/i, scope: 'agent' },
36
+ { pattern: /\b(job|task|queue)\b/i, scope: 'job' },
37
+ ];
38
+
39
+ export function extractIntent(prompts) {
40
+ if (!prompts || prompts.length === 0) {
41
+ return { goal: '', type: 'unknown', scope: [] };
42
+ }
43
+
44
+ // Use all prompts for classification, but first prompt is typically the main goal
45
+ const allText = prompts.map(p => typeof p === 'string' ? p : p.text || '').join(' ');
46
+ const firstPrompt = typeof prompts[0] === 'string' ? prompts[0] : prompts[0].text || '';
47
+
48
+ // Determine type by counting pattern matches across all prompts
49
+ const typeCounts = {};
50
+ for (const { pattern, type } of INTENT_PATTERNS) {
51
+ const matches = allText.match(new RegExp(pattern, 'gi'));
52
+ if (matches) typeCounts[type] = (typeCounts[type] || 0) + matches.length;
53
+ }
54
+
55
+ const type = Object.entries(typeCounts)
56
+ .sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown';
57
+
58
+ // Extract scopes
59
+ const scopes = new Set();
60
+ for (const { pattern, scope } of SCOPE_PATTERNS) {
61
+ if (pattern.test(allText)) scopes.add(scope);
62
+ }
63
+
64
+ // Goal: use first prompt, truncated
65
+ const goal = firstPrompt.slice(0, 200).trim();
66
+
67
+ return { goal, type, scope: [...scopes] };
68
+ }
69
+
70
+ // --- Change extraction from tool events ---
71
+
72
+ const FILE_CATEGORIES = [
73
+ { pattern: /\.(spec|test)\.(ts|js|tsx|jsx)$/, category: 'test' },
74
+ { pattern: /__(tests|mocks|fixtures)__\//, category: 'test' },
75
+ { pattern: /\.(config|rc)\.(ts|js|json|yaml|yml)$/, category: 'config' },
76
+ { pattern: /\.(env|env\.\w+)$/, category: 'config' },
77
+ { pattern: /\.gitignore|\.eslintrc|\.prettierrc|tsconfig/, category: 'config' },
78
+ { pattern: /prisma\/.*\.prisma$/, category: 'config' },
79
+ { pattern: /prisma\/migrations\//, category: 'migration' },
80
+ { pattern: /\.(md|txt|rst|doc)$/, category: 'doc' },
81
+ { pattern: /README|CHANGELOG|LICENSE|CONTRIBUTING/, category: 'doc' },
82
+ { pattern: /scripts\/|\.sh$/, category: 'script' },
83
+ { pattern: /Dockerfile|docker-compose|\.dockerignore/, category: 'infra' },
84
+ { pattern: /\.github\/|\.gitlab-ci|\.circleci/, category: 'infra' },
85
+ { pattern: /package(-lock)?\.json|yarn\.lock|pnpm-lock/, category: 'deps' },
86
+ ];
87
+
88
+ function categorizeFile(filePath) {
89
+ for (const { pattern, category } of FILE_CATEGORIES) {
90
+ if (pattern.test(filePath)) return category;
91
+ }
92
+ return 'source';
93
+ }
94
+
95
+ const COMMAND_CATEGORIES = [
96
+ { pattern: /\b(test|jest|vitest|mocha|cypress|playwright)\b/i, category: 'test' },
97
+ { pattern: /\b(build|compile|tsc|webpack|vite|rollup|esbuild)\b/i, category: 'build' },
98
+ { pattern: /\bgit\b/i, category: 'git' },
99
+ { pattern: /\b(deploy|ssh|scp|rsync|docker)\b/i, category: 'deploy' },
100
+ { pattern: /\b(npm|yarn|pnpm|pip|cargo)\s+(install|add|remove)/i, category: 'install' },
101
+ { pattern: /\b(lint|eslint|prettier|format)\b/i, category: 'lint' },
102
+ { pattern: /\b(prisma|migrate|migration)\b/i, category: 'database' },
103
+ ];
104
+
105
+ function categorizeCommand(cmd) {
106
+ for (const { pattern, category } of COMMAND_CATEGORIES) {
107
+ if (pattern.test(cmd)) return category;
108
+ }
109
+ return 'other';
110
+ }
111
+
112
+ export function extractChanges(state) {
113
+ const fileMap = new Map(); // path -> entry
114
+
115
+ // Process file operations
116
+ for (const path of (state.file_ops?.write || [])) {
117
+ const entry = getOrCreateFile(fileMap, path);
118
+ if (!entry.operations.includes('write')) entry.operations.push('write');
119
+ }
120
+ for (const path of (state.file_ops?.edit || [])) {
121
+ const entry = getOrCreateFile(fileMap, path);
122
+ if (!entry.operations.includes('edit')) entry.operations.push('edit');
123
+ }
124
+ for (const path of (state.file_ops?.read || [])) {
125
+ const entry = getOrCreateFile(fileMap, path);
126
+ if (!entry.operations.includes('read')) entry.operations.push('read');
127
+ }
128
+
129
+ // Enrich with edit counts and summaries
130
+ for (const edit of (state.edits || [])) {
131
+ const entry = fileMap.get(edit.file);
132
+ if (entry) {
133
+ entry.editCount = (entry.editCount || 0) + 1;
134
+ if (edit.new && !entry.editSummary) {
135
+ entry.editSummary = edit.new.slice(0, 100);
136
+ }
137
+ }
138
+ }
139
+
140
+ // Categorize commands
141
+ const commands = {};
142
+ for (const cmd of (state.commands || [])) {
143
+ const cat = categorizeCommand(cmd);
144
+ if (!commands[cat]) commands[cat] = [];
145
+ commands[cat].push(cmd);
146
+ }
147
+
148
+ // Build summary counts (modified files only)
149
+ const summary = {};
150
+ for (const [, entry] of fileMap) {
151
+ if (entry.operations.some(op => op !== 'read')) {
152
+ summary[entry.category] = (summary[entry.category] || 0) + 1;
153
+ }
154
+ }
155
+
156
+ return {
157
+ files: [...fileMap.values()],
158
+ commands,
159
+ summary,
160
+ };
161
+ }
162
+
163
+ function getOrCreateFile(fileMap, path) {
164
+ if (!fileMap.has(path)) {
165
+ fileMap.set(path, {
166
+ path,
167
+ category: categorizeFile(path),
168
+ operations: [],
169
+ editCount: 0,
170
+ editSummary: '',
171
+ });
172
+ }
173
+ return fileMap.get(path);
174
+ }
175
+
176
+ // --- Session classification ---
177
+
178
+ export function classifySession(intent, changes) {
179
+ const type = intent.type !== 'unknown' ? intent.type : inferTypeFromChanges(changes);
180
+
181
+ const risk = assessRisk(changes);
182
+
183
+ const testsRun = !!(changes.commands?.test?.length > 0);
184
+ const buildVerified = !!(changes.commands?.build?.length > 0);
185
+
186
+ // Find source files that lack corresponding test files
187
+ const sourceFiles = changes.files
188
+ .filter(f => f.category === 'source' && f.operations.some(op => op !== 'read'));
189
+ const testFiles = new Set(
190
+ changes.files.filter(f => f.category === 'test').map(f => f.path)
191
+ );
192
+ const filesWithoutTests = sourceFiles
193
+ .filter(f => {
194
+ const testVariants = [
195
+ f.path.replace(/\.(ts|js)$/, '.spec.$1'),
196
+ f.path.replace(/\.(ts|js)$/, '.test.$1'),
197
+ f.path.replace(/src\//, 'test/').replace(/\.(ts|js)$/, '.e2e-spec.$1'),
198
+ ];
199
+ return !testVariants.some(tv => testFiles.has(tv));
200
+ })
201
+ .map(f => f.path);
202
+
203
+ const modifiedFileCount = changes.files
204
+ .filter(f => f.operations.some(op => op !== 'read')).length;
205
+
206
+ const summary = buildSummary(intent, type, modifiedFileCount);
207
+
208
+ return {
209
+ type,
210
+ risk,
211
+ summary,
212
+ reviewHints: {
213
+ testsRun,
214
+ buildVerified,
215
+ filesWithoutTests,
216
+ largeChange: modifiedFileCount > 10,
217
+ configChanged: changes.files.some(f => f.category === 'config' && f.operations.some(op => op !== 'read')),
218
+ migrationAdded: changes.files.some(f => f.category === 'migration' && f.operations.some(op => op !== 'read')),
219
+ },
220
+ };
221
+ }
222
+
223
+ function inferTypeFromChanges(changes) {
224
+ if (changes.files.some(f => f.category === 'test' && f.operations.includes('write'))) return 'test';
225
+ if (changes.files.some(f => f.category === 'doc' && f.operations.some(op => op !== 'read'))) return 'docs';
226
+ if (changes.files.some(f => f.category === 'config' && f.operations.some(op => op !== 'read'))) return 'config';
227
+ if (changes.files.some(f => f.category === 'infra' && f.operations.some(op => op !== 'read'))) return 'devops';
228
+ return 'unknown';
229
+ }
230
+
231
+ function assessRisk(changes) {
232
+ let score = 0;
233
+ const modified = changes.files.filter(f => f.operations.some(op => op !== 'read'));
234
+
235
+ // More files = more risk
236
+ if (modified.length > 10) score += 2;
237
+ else if (modified.length > 5) score += 1;
238
+
239
+ // Config/migration changes are risky
240
+ if (modified.some(f => f.category === 'config')) score += 1;
241
+ if (modified.some(f => f.category === 'migration')) score += 2;
242
+ if (modified.some(f => f.category === 'infra')) score += 1;
243
+
244
+ // Tests reduce risk
245
+ if (changes.commands?.test?.length > 0) score -= 1;
246
+
247
+ if (score >= 3) return 'high';
248
+ if (score >= 1) return 'medium';
249
+ return 'low';
250
+ }
251
+
252
+ function buildSummary(intent, type, fileCount) {
253
+ const typeLabel = {
254
+ bugfix: 'Fixed', feature: 'Added', refactor: 'Refactored',
255
+ debug: 'Debugged', test: 'Tested', docs: 'Documented',
256
+ devops: 'DevOps', upgrade: 'Upgraded', config: 'Configured',
257
+ style: 'Styled', security: 'Security fix', perf: 'Optimized',
258
+ unknown: 'Modified',
259
+ }[type] || 'Modified';
260
+
261
+ if (intent.goal) {
262
+ return `${typeLabel}: ${intent.goal.slice(0, 150)}`;
263
+ }
264
+ return `${typeLabel} ${fileCount} file(s)`;
265
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shadow branch module - saves session logs to orphan git branches.
5
+ *
6
+ * Approach (same as .entire):
7
+ * - Creates orphan branches: shit/<base-commit>-<session-short>
8
+ * - Uses git plumbing commands (no checkout, no working tree impact)
9
+ * - Each session-end/stop creates a commit on the shadow branch
10
+ *
11
+ * Branch naming: shit/<HEAD-short>-<session-id-first-8>
12
+ */
13
+
14
+ import { readdirSync, readFileSync, statSync } from 'fs';
15
+ import { execSync } from 'child_process';
16
+ import { join, basename } from 'path';
17
+
18
+ function git(cmd, cwd) {
19
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
20
+ }
21
+
22
+ function gitRaw(cmd, cwd, input) {
23
+ return execSync(`git ${cmd}`, {
24
+ cwd, encoding: 'utf-8', timeout: 10000,
25
+ input,
26
+ }).trim();
27
+ }
28
+
29
+ /**
30
+ * Build a git tree from a directory using plumbing commands.
31
+ * Returns the tree SHA.
32
+ */
33
+ function buildTree(cwd, dirPath) {
34
+ const entries = readdirSync(dirPath);
35
+ const treeLines = [];
36
+
37
+ for (const name of entries) {
38
+ const fullPath = join(dirPath, name);
39
+ const stat = statSync(fullPath);
40
+
41
+ if (stat.isDirectory()) {
42
+ const subTreeSha = buildTree(cwd, fullPath);
43
+ treeLines.push(`040000 tree ${subTreeSha}\t${name}`);
44
+ } else {
45
+ const content = readFileSync(fullPath);
46
+ const blobSha = gitRaw('hash-object -w --stdin', cwd, content);
47
+ treeLines.push(`100644 blob ${blobSha}\t${name}`);
48
+ }
49
+ }
50
+
51
+ if (treeLines.length === 0) return null;
52
+ return gitRaw('mktree', cwd, treeLines.join('\n'));
53
+ }
54
+
55
+ /**
56
+ * Commit session logs to a shadow branch.
57
+ * Called on session-end or stop.
58
+ */
59
+ export async function commitShadow(projectRoot, sessionDir, sessionId) {
60
+ // Verify we're in a git repo
61
+ try {
62
+ git('rev-parse --git-dir', projectRoot);
63
+ } catch {
64
+ return; // not a git repo, skip
65
+ }
66
+
67
+ const headShort = git('rev-parse --short HEAD', projectRoot);
68
+ const sessionShort = sessionId.slice(0, 8);
69
+ const branchName = `shit/${headShort}-${sessionShort}`;
70
+ const refPath = `refs/heads/${branchName}`;
71
+
72
+ // Build tree from session directory
73
+ const sessionTree = buildTree(projectRoot, sessionDir);
74
+ if (!sessionTree) return;
75
+
76
+ // Wrap session files under .shit-logs/<session-id>/ in the tree
77
+ const wrapperLine = `040000 tree ${sessionTree}\t${sessionId}`;
78
+ const logsTree = gitRaw('mktree', projectRoot, wrapperLine);
79
+ const rootLine = `040000 tree ${logsTree}\t.shit-logs`;
80
+ const rootTree = gitRaw('mktree', projectRoot, rootLine);
81
+
82
+ // Check if branch already exists (for parent chaining)
83
+ let parentArg = '';
84
+ try {
85
+ const existing = git(`rev-parse ${refPath}`, projectRoot);
86
+ parentArg = `-p ${existing}`;
87
+ } catch {
88
+ // orphan - no parent
89
+ }
90
+
91
+ // Create commit
92
+ const timestamp = new Date().toISOString().replace('T', ' ').split('.')[0];
93
+ const message = `session ${sessionShort} @ ${timestamp}`;
94
+ const commitSha = git(
95
+ `commit-tree ${rootTree} ${parentArg} -m "${message}"`,
96
+ projectRoot
97
+ );
98
+
99
+ // Update branch ref
100
+ git(`update-ref ${refPath} ${commitSha}`, projectRoot);
101
+
102
+ // Save branch info to state
103
+ const stateFile = join(sessionDir, 'state.json');
104
+ try {
105
+ const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
106
+ state.shadow_branch = branchName;
107
+ const { writeFileSync } = await import('fs');
108
+ writeFileSync(stateFile, JSON.stringify(state, null, 2));
109
+ } catch { /* best effort */ }
110
+ }
111
+
112
+ /**
113
+ * List all shadow branches in the repo.
114
+ */
115
+ export function listShadowBranches(projectRoot) {
116
+ try {
117
+ const output = git('branch --list "shit/*"', projectRoot);
118
+ return output.split('\n').map(b => b.trim()).filter(Boolean);
119
+ } catch {
120
+ return [];
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Show shadow branch info.
126
+ */
127
+ export function shadowInfo(projectRoot, branchName) {
128
+ try {
129
+ const log = git(`log ${branchName} --oneline -5`, projectRoot);
130
+ const files = git(`ls-tree -r --name-only ${branchName}`, projectRoot);
131
+ return { log, files: files.split('\n') };
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
package/lib/init.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { getProjectRoot } from './config.js';
6
+
7
+ const HOOK_TYPES = [
8
+ 'SessionStart', 'SessionEnd', 'UserPromptSubmit',
9
+ 'PreToolUse', 'PostToolUse', 'Notification', 'Stop',
10
+ ];
11
+
12
+ const SHIT_COMMAND = (hookType) => `shit log ${hookType.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')}`;
13
+
14
+ export default async function init(args) {
15
+ const cwd = getProjectRoot();
16
+ const claudeDir = join(cwd, '.claude');
17
+ const settingsFile = join(claudeDir, 'settings.json');
18
+
19
+ console.log('Initializing shit-cli hooks...\n');
20
+
21
+ mkdirSync(claudeDir, { recursive: true });
22
+
23
+ // Load existing settings
24
+ let settings = {};
25
+ if (existsSync(settingsFile)) {
26
+ try {
27
+ settings = JSON.parse(readFileSync(settingsFile, 'utf-8'));
28
+ } catch {
29
+ console.log('Warning: could not parse settings.json, creating new');
30
+ }
31
+ }
32
+ if (!settings.hooks) settings.hooks = {};
33
+
34
+ // Register hooks
35
+ let added = 0, skipped = 0;
36
+
37
+ for (const hookType of HOOK_TYPES) {
38
+ const command = SHIT_COMMAND(hookType);
39
+ const existing = settings.hooks[hookType] || [];
40
+
41
+ const alreadyExists = existing.some(entry =>
42
+ entry.hooks?.some(h => h.command?.includes('shit log'))
43
+ );
44
+
45
+ if (alreadyExists) {
46
+ skipped++;
47
+ continue;
48
+ }
49
+
50
+ const catchAll = existing.find(entry => entry.matcher === '');
51
+ if (catchAll) {
52
+ catchAll.hooks = catchAll.hooks || [];
53
+ catchAll.hooks.push({ type: 'command', command });
54
+ } else {
55
+ existing.push({ matcher: '', hooks: [{ type: 'command', command }] });
56
+ }
57
+
58
+ settings.hooks[hookType] = existing;
59
+ added++;
60
+ }
61
+
62
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
63
+
64
+ console.log(`Added: ${added} hooks | Skipped: ${skipped} (already registered)`);
65
+ console.log('Hook types:', HOOK_TYPES.join(', '));
66
+
67
+ // Create .shit-logs and update .gitignore
68
+ mkdirSync(join(cwd, '.shit-logs'), { recursive: true });
69
+
70
+ const gitignoreFile = join(cwd, '.gitignore');
71
+ if (existsSync(gitignoreFile)) {
72
+ const content = readFileSync(gitignoreFile, 'utf-8');
73
+ if (!content.includes('.shit-logs')) {
74
+ appendFileSync(gitignoreFile, '\n# shit-cli logs\n.shit-logs/\n');
75
+ }
76
+ } else {
77
+ writeFileSync(gitignoreFile, '# shit-cli logs\n.shit-logs/\n');
78
+ }
79
+
80
+ console.log('\nLogs: .shit-logs/ (project-local)');
81
+ console.log('Shadow branches: shit/<commit>-<session> (git)');
82
+ console.log('\nDone. Start Claude Code and hooks will log automatically.');
83
+ }
package/lib/list.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { getProjectRoot, getLogDir, SESSION_ID_REGEX } from './config.js';
6
+
7
+ export default async function list(args) {
8
+ const logDir = getLogDir(getProjectRoot());
9
+
10
+ if (!existsSync(logDir)) {
11
+ console.log('No log directory found. Run "shit init" first.');
12
+ return;
13
+ }
14
+
15
+ const entries = readdirSync(logDir);
16
+ const sessions = entries
17
+ .filter(name => SESSION_ID_REGEX.test(name))
18
+ .map(id => {
19
+ const dir = join(logDir, id);
20
+ const metaFile = join(dir, 'metadata.json');
21
+ const stateFile = join(dir, 'state.json');
22
+ try {
23
+ const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
24
+ let state = null;
25
+ if (existsSync(stateFile)) {
26
+ state = JSON.parse(readFileSync(stateFile, 'utf-8'));
27
+ }
28
+ return { id, meta, state, mtime: statSync(dir).mtime };
29
+ } catch { return null; }
30
+ })
31
+ .filter(Boolean)
32
+ .sort((a, b) => b.mtime - a.mtime);
33
+
34
+ if (sessions.length === 0) {
35
+ console.log('No sessions found.');
36
+ return;
37
+ }
38
+
39
+ console.log(`${sessions.length} session(s):\n`);
40
+
41
+ sessions.forEach((s, i) => {
42
+ const m = s.meta;
43
+ const st = s.state;
44
+ const dur = m.duration_minutes ?? (st?.start_time && st?.last_time
45
+ ? Math.round((new Date(st.last_time) - new Date(st.start_time)) / 60000) : 0);
46
+ const type = m.type || 'unknown';
47
+ const risk = m.risk || '-';
48
+ const intent = m.intent || '';
49
+ const files = (m.files_touched || 0);
50
+ const errs = (m.errors || 0);
51
+ const shadow = st?.shadow_branch ? ` [${st.shadow_branch}]` : '';
52
+ const date = new Date(m.last_updated).toLocaleString();
53
+
54
+ console.log(`${i + 1}. ${s.id.slice(0, 8)} [${type}] risk:${risk}`);
55
+ if (intent) {
56
+ console.log(` ${intent.slice(0, 100)}`);
57
+ }
58
+ console.log(` ${dur}min | ${m.event_count} events | ${m.tool_calls || 0} tools | ${files} files | ${errs} errors${shadow}`);
59
+ console.log(` ${date}`);
60
+ console.log('');
61
+ });
62
+ }