@claudemini/shit-cli 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/rewind.js CHANGED
@@ -5,54 +5,71 @@
5
5
  * Similar to 'entire rewind' - rollback to known good state
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, writeFileSync } from 'fs';
9
- import { join } from 'path';
10
8
  import { execSync } from 'child_process';
11
-
12
- function findProjectRoot() {
13
- let dir = process.cwd();
14
- while (dir !== '/') {
15
- if (existsSync(join(dir, '.git'))) {
16
- return dir;
17
- }
18
- dir = join(dir, '..');
19
- }
20
- throw new Error('Not in a git repository');
21
- }
9
+ import { getProjectRoot } from './config.js';
22
10
 
23
11
  function git(cmd, cwd) {
24
12
  return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
25
13
  }
26
14
 
15
+ function parseCheckpointRef(projectRoot, branch) {
16
+ const shadowMatch = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
17
+ if (shadowMatch) {
18
+ return {
19
+ type: 'shadow',
20
+ baseCommit: shadowMatch[1],
21
+ sessionShort: shadowMatch[2],
22
+ lookupKey: shadowMatch[2],
23
+ };
24
+ }
25
+
26
+ const checkpointMatch = branch.match(/^shit\/checkpoints\/v1\/(\d{4}-\d{2}-\d{2})-([a-f0-9]+)$/);
27
+ if (checkpointMatch) {
28
+ const message = git(`log ${branch} --format=%B -1`, projectRoot);
29
+ const linkedMatch = message.match(/@ ([a-f0-9]+)/);
30
+ const date = checkpointMatch[1];
31
+ const sessionShort = checkpointMatch[2];
32
+ return {
33
+ type: 'checkpoint',
34
+ baseCommit: linkedMatch ? linkedMatch[1] : null,
35
+ sessionShort,
36
+ lookupKey: `${date}-${sessionShort}`,
37
+ };
38
+ }
39
+
40
+ return null;
41
+ }
42
+
27
43
  function listCheckpoints(projectRoot) {
28
44
  try {
29
- // Get shadow branches
30
- const branches = git('branch --list "shit/*"', projectRoot)
45
+ const branches = git('branch --list "shit/checkpoints/v1/*" "shit/*"', projectRoot)
31
46
  .split('\n')
32
47
  .map(b => b.trim().replace(/^\*?\s*/, ''))
33
48
  .filter(Boolean);
49
+ const uniqueBranches = [...new Set(branches)];
34
50
 
35
51
  const checkpoints = [];
36
52
 
37
- for (const branch of branches) {
53
+ for (const branch of uniqueBranches) {
38
54
  try {
55
+ const parsed = parseCheckpointRef(projectRoot, branch);
56
+ if (!parsed) {
57
+ continue;
58
+ }
59
+
39
60
  const log = git(`log ${branch} --oneline -1`, projectRoot);
40
61
  const [commit, ...messageParts] = log.split(' ');
41
62
  const message = messageParts.join(' ');
42
-
43
- // Extract session info from branch name: shit/<commit>-<session>
44
- const match = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
45
- if (match) {
46
- const [, baseCommit, sessionShort] = match;
47
- checkpoints.push({
48
- branch,
49
- commit,
50
- baseCommit,
51
- sessionShort,
52
- message,
53
- timestamp: git(`log ${branch} --format=%ci -1`, projectRoot)
54
- });
55
- }
63
+ checkpoints.push({
64
+ branch,
65
+ commit,
66
+ baseCommit: parsed.baseCommit,
67
+ sessionShort: parsed.sessionShort,
68
+ lookupKey: parsed.lookupKey,
69
+ type: parsed.type,
70
+ message,
71
+ timestamp: git(`log ${branch} --format=%ci -1`, projectRoot)
72
+ });
56
73
  } catch {
57
74
  // Skip invalid branches
58
75
  }
@@ -84,6 +101,10 @@ function hasUncommittedChanges(projectRoot) {
84
101
  function rewindToCheckpoint(projectRoot, checkpoint, force = false) {
85
102
  const currentCommit = getCurrentCommit(projectRoot);
86
103
 
104
+ if (!checkpoint.baseCommit) {
105
+ throw new Error(`Checkpoint ${checkpoint.lookupKey || checkpoint.sessionShort} is missing a linked base commit`);
106
+ }
107
+
87
108
  if (!force && hasUncommittedChanges(projectRoot)) {
88
109
  throw new Error('Working directory has uncommitted changes. Use --force to override or commit changes first.');
89
110
  }
@@ -100,7 +121,7 @@ function rewindToCheckpoint(projectRoot, checkpoint, force = false) {
100
121
 
101
122
  export default async function rewind(args) {
102
123
  try {
103
- const projectRoot = findProjectRoot();
124
+ const projectRoot = getProjectRoot();
104
125
  const force = args.includes('--force') || args.includes('-f');
105
126
  const interactive = args.includes('--interactive') || args.includes('-i');
106
127
 
@@ -112,7 +133,7 @@ export default async function rewind(args) {
112
133
 
113
134
  if (checkpoints.length === 0) {
114
135
  console.log('❌ No checkpoints found');
115
- console.log(' Checkpoints are created automatically when sessions end.');
136
+ console.log(' Checkpoints are created when you run "shit commit".');
116
137
  process.exit(1);
117
138
  }
118
139
 
@@ -120,7 +141,8 @@ export default async function rewind(args) {
120
141
  // Find specific checkpoint
121
142
  targetCheckpoint = checkpoints.find(cp =>
122
143
  cp.sessionShort.startsWith(checkpointArg) ||
123
- cp.baseCommit.startsWith(checkpointArg)
144
+ cp.lookupKey.startsWith(checkpointArg) ||
145
+ (cp.baseCommit && cp.baseCommit.startsWith(checkpointArg))
124
146
  );
125
147
 
126
148
  if (!targetCheckpoint) {
@@ -132,22 +154,25 @@ export default async function rewind(args) {
132
154
  console.log('📋 Available checkpoints:\n');
133
155
  checkpoints.forEach((cp, i) => {
134
156
  const date = new Date(cp.timestamp).toLocaleString();
135
- console.log(`${i + 1}. ${cp.sessionShort} (${cp.baseCommit.slice(0, 7)}) - ${date}`);
157
+ const base = cp.baseCommit ? cp.baseCommit.slice(0, 7) : 'unknown';
158
+ const key = cp.lookupKey || cp.sessionShort;
159
+ console.log(`${i + 1}. ${key} (${base}) - ${date}`);
136
160
  console.log(` ${cp.message}`);
137
161
  console.log();
138
162
  });
139
163
 
140
164
  // For now, just use the most recent
141
165
  targetCheckpoint = checkpoints[0];
142
- console.log(`Using most recent checkpoint: ${targetCheckpoint.sessionShort}`);
166
+ console.log(`Using most recent checkpoint: ${targetCheckpoint.lookupKey || targetCheckpoint.sessionShort}`);
143
167
  } else {
144
168
  // Use most recent checkpoint
145
169
  targetCheckpoint = checkpoints[0];
146
170
  }
147
171
 
148
- console.log(`🔄 Rewinding to checkpoint: ${targetCheckpoint.sessionShort}`);
172
+ const selectedKey = targetCheckpoint.lookupKey || targetCheckpoint.sessionShort;
173
+ console.log(`🔄 Rewinding to checkpoint: ${selectedKey}`);
149
174
  console.log(` Branch: ${targetCheckpoint.branch}`);
150
- console.log(` Base commit: ${targetCheckpoint.baseCommit}`);
175
+ console.log(` Base commit: ${targetCheckpoint.baseCommit || 'unknown'}`);
151
176
  console.log(` Created: ${new Date(targetCheckpoint.timestamp).toLocaleString()}`);
152
177
  console.log();
153
178
 
@@ -164,7 +189,7 @@ export default async function rewind(args) {
164
189
  console.log(` git reset --hard ${previousCommit}`);
165
190
  console.log();
166
191
  console.log('💡 To resume from this checkpoint:');
167
- console.log(` shit resume ${targetCheckpoint.sessionShort}`);
192
+ console.log(` shit resume ${selectedKey}`);
168
193
 
169
194
  } catch (error) {
170
195
  console.error('❌ Failed to rewind:', error.message);
package/lib/session.js CHANGED
@@ -105,6 +105,12 @@ export function processEvent(state, event, hookType, projectRoot) {
105
105
  return;
106
106
  }
107
107
 
108
+ // Mark session as ended for end/stop hooks.
109
+ if (hookType === 'session-end' || hookType === 'SessionEnd' || hookType === 'stop' || hookType === 'session_end' || hookType === 'end') {
110
+ state.end_time = now;
111
+ return;
112
+ }
113
+
108
114
  // Tool events
109
115
  if (hookType === 'post-tool-use' && toolName) {
110
116
  state.tool_counts[toolName] = (state.tool_counts[toolName] || 0) + 1;
package/lib/status.js CHANGED
@@ -5,20 +5,10 @@
5
5
  * Similar to 'entire status' - displays active session info
6
6
  */
7
7
 
8
- import { existsSync, readFileSync } from 'fs';
8
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { execSync } from 'child_process';
11
-
12
- function findProjectRoot() {
13
- let dir = process.cwd();
14
- while (dir !== '/') {
15
- if (existsSync(join(dir, '.git'))) {
16
- return dir;
17
- }
18
- dir = join(dir, '..');
19
- }
20
- throw new Error('Not in a git repository');
21
- }
11
+ import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
22
12
 
23
13
  function getCurrentSession(projectRoot) {
24
14
  const shitLogsDir = join(projectRoot, '.shit-logs');
@@ -27,9 +17,11 @@ function getCurrentSession(projectRoot) {
27
17
  }
28
18
 
29
19
  // Find the most recent session directory
30
- const { readdirSync, statSync } = await import('fs');
31
20
  const sessions = readdirSync(shitLogsDir)
32
- .filter(name => name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/)) // session format
21
+ .filter(name => {
22
+ const fullPath = join(shitLogsDir, name);
23
+ return SESSION_ID_REGEX.test(name) && statSync(fullPath).isDirectory();
24
+ })
33
25
  .map(name => ({
34
26
  name,
35
27
  path: join(shitLogsDir, name),
@@ -90,11 +82,45 @@ function formatDuration(startTime) {
90
82
  }
91
83
  }
92
84
 
85
+ function getTouchedFileCount(state) {
86
+ const ops = state?.file_ops;
87
+ if (!ops || typeof ops !== 'object') {
88
+ return 0;
89
+ }
90
+
91
+ const touched = new Set([
92
+ ...(Array.isArray(ops.write) ? ops.write : []),
93
+ ...(Array.isArray(ops.edit) ? ops.edit : []),
94
+ ...(Array.isArray(ops.read) ? ops.read : []),
95
+ ].filter(Boolean));
96
+
97
+ return touched.size;
98
+ }
99
+
100
+ function hasShitHooks(settings) {
101
+ if (!settings?.hooks || typeof settings.hooks !== 'object') {
102
+ return false;
103
+ }
104
+
105
+ return Object.values(settings.hooks).some(value => {
106
+ if (typeof value === 'string') {
107
+ return value.includes('shit log');
108
+ }
109
+ if (!Array.isArray(value)) {
110
+ return false;
111
+ }
112
+ return value.some(entry =>
113
+ Array.isArray(entry?.hooks) &&
114
+ entry.hooks.some(hook => typeof hook?.command === 'string' && hook.command.includes('shit log'))
115
+ );
116
+ });
117
+ }
118
+
93
119
  export default async function status(args) {
94
120
  try {
95
- const projectRoot = findProjectRoot();
121
+ const projectRoot = getProjectRoot();
96
122
  const gitInfo = getGitInfo(projectRoot);
97
- const currentSession = await getCurrentSession(projectRoot);
123
+ const currentSession = getCurrentSession(projectRoot);
98
124
 
99
125
  console.log('📊 shit-cli Status\n');
100
126
 
@@ -121,7 +147,7 @@ export default async function status(args) {
121
147
  console.log(` Started: ${new Date(state.start_time).toLocaleString()}`);
122
148
  console.log(` Duration: ${formatDuration(state.start_time)}`);
123
149
  console.log(` Events: ${state.event_count || 0}`);
124
- console.log(` Files: ${Object.keys(state.files || {}).length}`);
150
+ console.log(` Files: ${getTouchedFileCount(state)}`);
125
151
 
126
152
  if (state.shadow_branch) {
127
153
  console.log(` Shadow: ${state.shadow_branch}`);
@@ -149,8 +175,7 @@ export default async function status(args) {
149
175
  if (existsSync(claudeSettings)) {
150
176
  try {
151
177
  const settings = JSON.parse(readFileSync(claudeSettings, 'utf-8'));
152
- const hasHooks = settings.hooks &&
153
- (settings.hooks.session_start || settings.hooks.session_end);
178
+ const hasHooks = hasShitHooks(settings);
154
179
 
155
180
  console.log();
156
181
  if (hasHooks) {
package/lib/summarize.js CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { existsSync, readFileSync, writeFileSync } from 'fs';
10
10
  import { join } from 'path';
11
- import { execSync } from 'child_process';
11
+ import { getProjectRoot } from './config.js';
12
12
 
13
13
  // Default configuration
14
14
  const DEFAULT_CONFIG = {
@@ -294,7 +294,7 @@ export async function summarizeSession(projectRoot, sessionId, sessionDir) {
294
294
  * CLI command for manual summarization
295
295
  */
296
296
  export default async function summarize(args) {
297
- const projectRoot = findProjectRoot();
297
+ const projectRoot = getProjectRoot();
298
298
  const sessionId = args[0];
299
299
 
300
300
  if (!sessionId) {
@@ -329,14 +329,3 @@ export default async function summarize(args) {
329
329
  process.exit(1);
330
330
  }
331
331
  }
332
-
333
- function findProjectRoot() {
334
- let dir = process.cwd();
335
- while (dir !== '/') {
336
- if (existsSync(join(dir, '.git'))) {
337
- return dir;
338
- }
339
- dir = join(dir, '..');
340
- }
341
- throw new Error('Not in a git repository');
342
- }
package/package.json CHANGED
@@ -1,22 +1,38 @@
1
1
  {
2
2
  "name": "@claudemini/shit-cli",
3
- "version": "1.4.0",
4
- "description": "Session-based Hook Intelligence Tracker - CLI tool for logging Claude Code hooks",
3
+ "version": "1.6.0",
4
+ "description": "Session-based Hook Intelligence Tracker - Zero-dependency memory system for human-AI coding sessions",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "shit": "./bin/shit.js"
8
8
  },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ },
9
17
  "scripts": {
10
18
  "test": "echo \"Error: no test specified\" && exit 1"
11
19
  },
12
20
  "keywords": [
13
21
  "claude-code",
22
+ "gemini-cli",
23
+ "cursor",
24
+ "ai-coding",
14
25
  "hooks",
15
- "logging",
16
- "session-tracking"
26
+ "session-tracking",
27
+ "code-review",
28
+ "checkpoint"
17
29
  ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/anthropics/shit-cli.git"
33
+ },
18
34
  "author": "",
19
- "license": "MIT",
35
+ "license": "UNLICENSED",
20
36
  "dependencies": {},
21
37
  "devDependencies": {}
22
38
  }
@@ -1,81 +0,0 @@
1
- {
2
- "hooks": {
3
- "SessionStart": [
4
- {
5
- "matcher": "",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "command": "shit log session-start"
10
- }
11
- ]
12
- }
13
- ],
14
- "SessionEnd": [
15
- {
16
- "matcher": "",
17
- "hooks": [
18
- {
19
- "type": "command",
20
- "command": "shit log session-end"
21
- }
22
- ]
23
- }
24
- ],
25
- "UserPromptSubmit": [
26
- {
27
- "matcher": "",
28
- "hooks": [
29
- {
30
- "type": "command",
31
- "command": "shit log user-prompt-submit"
32
- }
33
- ]
34
- }
35
- ],
36
- "PreToolUse": [
37
- {
38
- "matcher": "",
39
- "hooks": [
40
- {
41
- "type": "command",
42
- "command": "shit log pre-tool-use"
43
- }
44
- ]
45
- }
46
- ],
47
- "PostToolUse": [
48
- {
49
- "matcher": "",
50
- "hooks": [
51
- {
52
- "type": "command",
53
- "command": "shit log post-tool-use"
54
- }
55
- ]
56
- }
57
- ],
58
- "Notification": [
59
- {
60
- "matcher": "",
61
- "hooks": [
62
- {
63
- "type": "command",
64
- "command": "shit log notification"
65
- }
66
- ]
67
- }
68
- ],
69
- "Stop": [
70
- {
71
- "matcher": "",
72
- "hooks": [
73
- {
74
- "type": "command",
75
- "command": "shit log stop"
76
- }
77
- ]
78
- }
79
- ]
80
- }
81
- }
@@ -1,20 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(git remote add:*)",
5
- "Bash(git push:*)",
6
- "Bash(npm publish:*)",
7
- "Bash(npm whoami:*)",
8
- "WebFetch(domain:www.npmjs.com)",
9
- "Bash(npm config delete:*)",
10
- "Bash(sudo chown:*)",
11
- "Bash(npm cache clean:*)",
12
- "Bash(npm install:*)",
13
- "Bash(git add:*)",
14
- "Bash(git commit -m \"$\\(cat <<''EOF''\n更新包名和版本号\n\n- 包名改为 @cluademini/shit-cli\n- 版本号升至 1.0.3\nEOF\n\\)\")",
15
- "Bash(git commit:*)",
16
- "Bash(npm config set:*)",
17
- "WebFetch(domain:github.com)"
18
- ]
19
- }
20
- }
package/COMPARISON.md DELETED
@@ -1,92 +0,0 @@
1
- # Project Structure Comparison
2
-
3
- ## Directory Layout
4
-
5
- ```
6
- your-project/
7
- ├── .claude/ # Claude Code configuration
8
- │ └── settings.json # Hook configurations
9
- ├── .entire/ # Entire tool (session management)
10
- │ ├── metadata/
11
- │ │ └── <session-id>/
12
- │ │ ├── full.jsonl # Complete transcript
13
- │ │ ├── prompt.txt # User prompts
14
- │ │ ├── context.md # Session context
15
- │ │ └── summary.txt # Session summary
16
- │ ├── logs/
17
- │ └── settings.json
18
- └── .shit-logs/ # shit-cli (hook event logging)
19
- ├── <session-id>/
20
- │ ├── events.jsonl # Hook events only
21
- │ ├── prompts.txt # User prompts
22
- │ ├── context.md # Session context
23
- │ ├── summary.txt # Session summary
24
- │ └── metadata.json # Session metadata
25
- └── index.txt # Global index
26
- ```
27
-
28
- ## Feature Comparison
29
-
30
- | Feature | `.entire` | `.shit-logs` |
31
- |---------|-----------|--------------|
32
- | **Scope** | Complete session transcript | Hook events only |
33
- | **Location** | Project root | Project root ✓ |
34
- | **Session-based** | ✓ | ✓ |
35
- | **Transcript** | `full.jsonl` (all messages) | `events.jsonl` (hooks only) |
36
- | **Prompts** | `prompt.txt` | `prompts.txt` |
37
- | **Context** | `context.md` | `context.md` |
38
- | **Summary** | `summary.txt` | `summary.txt` |
39
- | **Checkpoints** | Git shadow branches | Not implemented |
40
- | **CLI** | `entire` commands | `shit` commands |
41
- | **Auto-init** | `entire enable` | `shit init` |
42
-
43
- ## Key Differences
44
-
45
- ### `.entire` (Entire Tool)
46
- - **Purpose**: Complete session management and checkpointing
47
- - **Data**: Full conversation transcript (user + assistant messages)
48
- - **Features**: Git integration, checkpoints, session replay
49
- - **Trigger**: Automatic (entire daemon)
50
-
51
- ### `.shit-logs` (shit-cli)
52
- - **Purpose**: Hook event logging and analysis
53
- - **Data**: Hook events only (tool calls, session events)
54
- - **Features**: Session aggregation, event filtering, cleanup
55
- - **Trigger**: Hook-based (Claude Code hooks)
56
-
57
- ## Use Cases
58
-
59
- ### Use `.entire` when you need:
60
- - Complete session history
61
- - Git-based checkpointing
62
- - Session replay and analysis
63
- - Cross-session context
64
-
65
- ### Use `.shit-logs` when you need:
66
- - Hook event debugging
67
- - Tool usage analysis
68
- - Session statistics
69
- - Lightweight logging
70
-
71
- ## Both Together
72
-
73
- Running both systems provides:
74
- - **Complete coverage**: Full transcript + hook events
75
- - **Different perspectives**: Conversation flow + tool execution
76
- - **Complementary data**: `.entire` for context, `.shit-logs` for debugging
77
-
78
- ## .gitignore
79
-
80
- Both directories are typically excluded from git:
81
-
82
- ```gitignore
83
- # Entire tool
84
- .entire/store/
85
- .entire/monitor.pid
86
- .entire/monitor.sessions.json
87
-
88
- # shit-cli logs
89
- .shit-logs/
90
- ```
91
-
92
- Note: `.entire/settings.json` and `.entire/.gitignore` are usually committed.