@moshi-labs/snitch 1.0.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/README.md +53 -0
- package/bin/snitch.js +61 -0
- package/cli/client.js +100 -0
- package/cli/collector.js +185 -0
- package/cli/commands/init.js +62 -0
- package/cli/commands/logs.js +49 -0
- package/cli/commands/start.js +36 -0
- package/cli/commands/status.js +38 -0
- package/cli/commands/stop.js +15 -0
- package/cli/config.js +82 -0
- package/cli/daemon-process.js +70 -0
- package/cli/daemon.js +139 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @moshi-labs/snitch
|
|
2
|
+
|
|
3
|
+
CLI daemon that reports Claude Code activity to a team server.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @moshi-labs/snitch
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
snitch init
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
You'll be prompted for:
|
|
18
|
+
- **Server URL** - Your team's snitch server
|
|
19
|
+
- **API Key** - Team API key for authentication
|
|
20
|
+
- **Your name** - How you appear on the dashboard
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
snitch start # Start the background daemon
|
|
26
|
+
snitch stop # Stop the daemon
|
|
27
|
+
snitch status # Check if daemon is running
|
|
28
|
+
snitch logs # View recent logs
|
|
29
|
+
snitch logs -f # Follow logs in real-time
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## How It Works
|
|
33
|
+
|
|
34
|
+
1. Daemon watches `~/.claude/projects/` for Claude Code session activity
|
|
35
|
+
2. Extracts tool calls and messages from moshi-labs repos
|
|
36
|
+
3. POSTs activity to your team server
|
|
37
|
+
4. Server categorizes work and displays on dashboard
|
|
38
|
+
|
|
39
|
+
## Config
|
|
40
|
+
|
|
41
|
+
Stored in `~/.snitch/config.json`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"serverUrl": "http://your-server:3333",
|
|
46
|
+
"apiKey": "your-team-key",
|
|
47
|
+
"userName": "Your Name"
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT
|
package/bin/snitch.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('snitch')
|
|
15
|
+
.description('Team awareness CLI for Claude Code sessions')
|
|
16
|
+
.version(pkg.version);
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('init')
|
|
20
|
+
.description('Configure snitch (server URL, API key, user name)')
|
|
21
|
+
.action(async () => {
|
|
22
|
+
const { initCommand } = await import('../cli/commands/init.js');
|
|
23
|
+
await initCommand();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command('start')
|
|
28
|
+
.description('Start the background daemon')
|
|
29
|
+
.option('-f, --foreground', 'Run in foreground (for debugging)')
|
|
30
|
+
.action(async (options) => {
|
|
31
|
+
const { startCommand } = await import('../cli/commands/start.js');
|
|
32
|
+
startCommand(options);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
program
|
|
36
|
+
.command('stop')
|
|
37
|
+
.description('Stop the background daemon')
|
|
38
|
+
.action(async () => {
|
|
39
|
+
const { stopCommand } = await import('../cli/commands/stop.js');
|
|
40
|
+
stopCommand();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
program
|
|
44
|
+
.command('status')
|
|
45
|
+
.description('Show daemon status')
|
|
46
|
+
.action(async () => {
|
|
47
|
+
const { statusCommand } = await import('../cli/commands/status.js');
|
|
48
|
+
statusCommand();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.command('logs')
|
|
53
|
+
.description('View daemon logs')
|
|
54
|
+
.option('-n, --lines <number>', 'Number of lines to show', '50')
|
|
55
|
+
.option('-f, --follow', 'Follow log output')
|
|
56
|
+
.action(async (options) => {
|
|
57
|
+
const { logsCommand } = await import('../cli/commands/logs.js');
|
|
58
|
+
logsCommand({ ...options, lines: parseInt(options.lines, 10) });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
program.parse();
|
package/cli/client.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { load } from './config.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HTTP client for posting activity to the snitch server
|
|
5
|
+
*/
|
|
6
|
+
export class SnitchClient {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.queue = [];
|
|
9
|
+
this.isProcessing = false;
|
|
10
|
+
this.maxRetries = 3;
|
|
11
|
+
this.retryDelayMs = 5000;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Post activity to the server
|
|
16
|
+
*/
|
|
17
|
+
async postActivity(activity) {
|
|
18
|
+
const config = load();
|
|
19
|
+
|
|
20
|
+
if (!config.serverUrl || !config.apiKey) {
|
|
21
|
+
console.error('[client] Not configured. Run: snitch init');
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const url = `${config.serverUrl}/api/activity`;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(url, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'X-API-Secret': config.apiKey,
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify(activity),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const error = await response.text();
|
|
39
|
+
console.error(`[client] Server error: ${response.status} - ${error}`);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return true;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(`[client] Network error: ${err.message}`);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Queue activity for posting with retry logic
|
|
52
|
+
*/
|
|
53
|
+
enqueue(activity) {
|
|
54
|
+
this.queue.push({ activity, retries: 0 });
|
|
55
|
+
this.processQueue();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Process queued activities
|
|
60
|
+
*/
|
|
61
|
+
async processQueue() {
|
|
62
|
+
if (this.isProcessing || this.queue.length === 0) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.isProcessing = true;
|
|
67
|
+
|
|
68
|
+
while (this.queue.length > 0) {
|
|
69
|
+
const item = this.queue[0];
|
|
70
|
+
|
|
71
|
+
const success = await this.postActivity(item.activity);
|
|
72
|
+
|
|
73
|
+
if (success) {
|
|
74
|
+
this.queue.shift(); // Remove from queue
|
|
75
|
+
console.log(`[client] Posted activity for session ${item.activity.sessionId.slice(0, 8)}`);
|
|
76
|
+
} else {
|
|
77
|
+
item.retries++;
|
|
78
|
+
if (item.retries >= this.maxRetries) {
|
|
79
|
+
console.error(`[client] Giving up on activity after ${this.maxRetries} retries`);
|
|
80
|
+
this.queue.shift();
|
|
81
|
+
} else {
|
|
82
|
+
console.log(`[client] Will retry in ${this.retryDelayMs / 1000}s (attempt ${item.retries}/${this.maxRetries})`);
|
|
83
|
+
// Move to end of queue and wait before retrying
|
|
84
|
+
this.queue.shift();
|
|
85
|
+
this.queue.push(item);
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, this.retryDelayMs));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.isProcessing = false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get queue size
|
|
96
|
+
*/
|
|
97
|
+
getQueueSize() {
|
|
98
|
+
return this.queue.length;
|
|
99
|
+
}
|
|
100
|
+
}
|
package/cli/collector.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import chokidar from 'chokidar';
|
|
2
|
+
import { readFileSync, statSync, existsSync } from 'fs';
|
|
3
|
+
import { join, basename, dirname } from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
|
|
7
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Watches session JSONL files and emits activity events
|
|
11
|
+
*/
|
|
12
|
+
export class ActivityCollector {
|
|
13
|
+
constructor(onActivity, userName) {
|
|
14
|
+
this.onActivity = onActivity;
|
|
15
|
+
this.userName = userName;
|
|
16
|
+
this.watcher = null;
|
|
17
|
+
this.filePositions = new Map(); // Track read position per file
|
|
18
|
+
this.activeThresholdMs = 5 * 60 * 1000; // 5 minutes = active
|
|
19
|
+
// Only track these repos
|
|
20
|
+
this.allowedRepos = ['moshi-api', 'moshi-frontend', 'moshi-e2e', 'terraform', 'browser-use', 'claude-team-viewer', 'snitch'];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
start() {
|
|
24
|
+
// Watch all .jsonl files in projects directory
|
|
25
|
+
const pattern = join(CLAUDE_PROJECTS_DIR, '*', '*.jsonl');
|
|
26
|
+
|
|
27
|
+
this.watcher = chokidar.watch(pattern, {
|
|
28
|
+
ignoreInitial: true, // Don't process existing files on startup
|
|
29
|
+
awaitWriteFinish: { stabilityThreshold: 200 },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
this.watcher.on('add', (path) => this.handleFile(path, true));
|
|
33
|
+
this.watcher.on('change', (path) => this.handleFile(path, false));
|
|
34
|
+
|
|
35
|
+
console.log(`[collector] Watching for activity in ${CLAUDE_PROJECTS_DIR}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
handleFile(filePath, isNew) {
|
|
39
|
+
// Skip sessions-index.json
|
|
40
|
+
if (basename(filePath) === 'sessions-index.json') return;
|
|
41
|
+
|
|
42
|
+
const sessionId = basename(filePath, '.jsonl');
|
|
43
|
+
const projectDir = basename(dirname(filePath));
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const stat = statSync(filePath);
|
|
47
|
+
const lastModified = stat.mtime;
|
|
48
|
+
const isActive = (Date.now() - lastModified.getTime()) < this.activeThresholdMs;
|
|
49
|
+
|
|
50
|
+
// Read new content since last position
|
|
51
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
52
|
+
const lastPos = this.filePositions.get(filePath) || 0;
|
|
53
|
+
const newContent = content.slice(lastPos);
|
|
54
|
+
this.filePositions.set(filePath, content.length);
|
|
55
|
+
|
|
56
|
+
if (!newContent.trim()) return;
|
|
57
|
+
|
|
58
|
+
// Only process if file was modified in last hour
|
|
59
|
+
const oneHourAgo = Date.now() - (60 * 60 * 1000);
|
|
60
|
+
if (lastModified.getTime() < oneHourAgo) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Parse new lines
|
|
65
|
+
const lines = newContent.trim().split('\n');
|
|
66
|
+
const events = [];
|
|
67
|
+
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
try {
|
|
70
|
+
const event = JSON.parse(line);
|
|
71
|
+
events.push(event);
|
|
72
|
+
} catch {
|
|
73
|
+
// Skip malformed lines
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (events.length === 0) return;
|
|
78
|
+
|
|
79
|
+
// Extract activity from events
|
|
80
|
+
const activity = this.extractActivity(sessionId, projectDir, events, lastModified, isActive);
|
|
81
|
+
|
|
82
|
+
if (activity) {
|
|
83
|
+
// Only emit if working on an allowed repo
|
|
84
|
+
if (activity.repo && this.allowedRepos.includes(activity.repo)) {
|
|
85
|
+
this.onActivity(activity);
|
|
86
|
+
} else if (projectDir.includes('moshi')) {
|
|
87
|
+
// Also allow if the project dir contains 'moshi'
|
|
88
|
+
this.onActivity(activity);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// File might be mid-write
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
extractActivity(sessionId, projectDir, events, lastModified, isActive) {
|
|
97
|
+
const activity = {
|
|
98
|
+
sessionId,
|
|
99
|
+
user: this.userName,
|
|
100
|
+
timestamp: lastModified.toISOString(),
|
|
101
|
+
isActive,
|
|
102
|
+
events: [],
|
|
103
|
+
repo: null,
|
|
104
|
+
branch: null,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const event of events) {
|
|
108
|
+
// Extract user messages
|
|
109
|
+
if (event.type === 'user' && event.message?.content) {
|
|
110
|
+
const content = typeof event.message.content === 'string'
|
|
111
|
+
? event.message.content
|
|
112
|
+
: event.message.content.map(c => c.text || '').join('');
|
|
113
|
+
activity.events.push({
|
|
114
|
+
type: 'user_message',
|
|
115
|
+
content: content.slice(0, 500), // Truncate
|
|
116
|
+
timestamp: event.timestamp,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Extract assistant tool calls
|
|
121
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
122
|
+
for (const block of event.message.content) {
|
|
123
|
+
if (block.type === 'tool_use') {
|
|
124
|
+
const toolEvent = {
|
|
125
|
+
type: 'tool_call',
|
|
126
|
+
tool: block.name,
|
|
127
|
+
timestamp: event.timestamp,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Extract repo/branch hints from tool calls
|
|
131
|
+
if (block.name === 'Bash' && block.input?.command) {
|
|
132
|
+
const cmd = block.input.command;
|
|
133
|
+
// Detect git branch switches
|
|
134
|
+
const branchMatch = cmd.match(/git (?:checkout|switch)(?: -b)? ([^\s&|;]+)/);
|
|
135
|
+
if (branchMatch) {
|
|
136
|
+
activity.branch = branchMatch[1];
|
|
137
|
+
}
|
|
138
|
+
// Detect cd into repos
|
|
139
|
+
const cdMatch = cmd.match(/cd\s+['"]*([^'"&|;\s]+)/);
|
|
140
|
+
if (cdMatch) {
|
|
141
|
+
const path = cdMatch[1];
|
|
142
|
+
const repoMatch = path.match(/moshi-(api|frontend|e2e)|terraform|browser-use|claude-team-viewer|snitch/);
|
|
143
|
+
if (repoMatch) {
|
|
144
|
+
activity.repo = repoMatch[0];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
toolEvent.hint = cmd.slice(0, 200);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Extract repo hints from file operations
|
|
151
|
+
if (['Read', 'Edit', 'Write'].includes(block.name) && block.input?.file_path) {
|
|
152
|
+
const path = block.input.file_path;
|
|
153
|
+
const repoMatch = path.match(/moshi-(api|frontend|e2e)|terraform|browser-use|claude-team-viewer|snitch/);
|
|
154
|
+
if (repoMatch) {
|
|
155
|
+
activity.repo = repoMatch[0];
|
|
156
|
+
}
|
|
157
|
+
toolEvent.hint = path;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
activity.events.push(toolEvent);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Extract session metadata
|
|
166
|
+
if (event.gitBranch) {
|
|
167
|
+
activity.branch = event.gitBranch;
|
|
168
|
+
}
|
|
169
|
+
if (event.cwd) {
|
|
170
|
+
const repoMatch = event.cwd.match(/moshi-(api|frontend|e2e)|terraform|browser-use|claude-team-viewer|snitch/);
|
|
171
|
+
if (repoMatch) {
|
|
172
|
+
activity.repo = repoMatch[0];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return activity.events.length > 0 ? activity : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
stop() {
|
|
181
|
+
if (this.watcher) {
|
|
182
|
+
this.watcher.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { load, save, paths } from '../config.js';
|
|
2
|
+
import * as readline from 'readline';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Interactive configuration setup
|
|
6
|
+
*/
|
|
7
|
+
export async function initCommand() {
|
|
8
|
+
const config = load();
|
|
9
|
+
|
|
10
|
+
console.log('Snitch Configuration');
|
|
11
|
+
console.log('====================\n');
|
|
12
|
+
|
|
13
|
+
const rl = readline.createInterface({
|
|
14
|
+
input: process.stdin,
|
|
15
|
+
output: process.stdout,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const question = (prompt) => new Promise((resolve) => {
|
|
19
|
+
rl.question(prompt, resolve);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// Server URL
|
|
24
|
+
const serverUrlPrompt = config.serverUrl
|
|
25
|
+
? `Server URL [${config.serverUrl}]: `
|
|
26
|
+
: 'Server URL: ';
|
|
27
|
+
const serverUrl = await question(serverUrlPrompt);
|
|
28
|
+
if (serverUrl.trim()) {
|
|
29
|
+
config.serverUrl = serverUrl.trim();
|
|
30
|
+
} else if (!config.serverUrl) {
|
|
31
|
+
console.log('Error: Server URL is required');
|
|
32
|
+
rl.close();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// API Key
|
|
37
|
+
const apiKeyPrompt = config.apiKey
|
|
38
|
+
? `API Key [***]: `
|
|
39
|
+
: 'API Key: ';
|
|
40
|
+
const apiKey = await question(apiKeyPrompt);
|
|
41
|
+
if (apiKey.trim()) {
|
|
42
|
+
config.apiKey = apiKey.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// User Name
|
|
46
|
+
const defaultName = config.userName || process.env.USER || '';
|
|
47
|
+
const userName = await question(`Your name [${defaultName}]: `);
|
|
48
|
+
config.userName = userName.trim() || defaultName;
|
|
49
|
+
|
|
50
|
+
save(config);
|
|
51
|
+
|
|
52
|
+
console.log('\nConfiguration saved to:', paths.config);
|
|
53
|
+
console.log('\nCurrent config:');
|
|
54
|
+
console.log(` Server URL: ${config.serverUrl}`);
|
|
55
|
+
console.log(` API Key: ${config.apiKey ? '***' : '(not set)'}`);
|
|
56
|
+
console.log(` User Name: ${config.userName}`);
|
|
57
|
+
console.log('\nRun "snitch start" to begin tracking activity.');
|
|
58
|
+
|
|
59
|
+
} finally {
|
|
60
|
+
rl.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, readFileSync, watchFile, statSync } from 'fs';
|
|
2
|
+
import { paths } from '../config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tail daemon logs
|
|
6
|
+
*/
|
|
7
|
+
export function logsCommand(options) {
|
|
8
|
+
const logFile = paths.log;
|
|
9
|
+
|
|
10
|
+
if (!existsSync(logFile)) {
|
|
11
|
+
console.log('No log file found. Start the daemon first: snitch start');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const lines = options.lines || 50;
|
|
16
|
+
|
|
17
|
+
// Read last N lines
|
|
18
|
+
const content = readFileSync(logFile, 'utf-8');
|
|
19
|
+
const allLines = content.split('\n').filter(Boolean);
|
|
20
|
+
const lastLines = allLines.slice(-lines);
|
|
21
|
+
|
|
22
|
+
console.log(`=== Last ${Math.min(lines, lastLines.length)} log entries ===\n`);
|
|
23
|
+
console.log(lastLines.join('\n'));
|
|
24
|
+
|
|
25
|
+
if (options.follow) {
|
|
26
|
+
console.log('\n=== Following log file (Ctrl+C to stop) ===\n');
|
|
27
|
+
|
|
28
|
+
let lastSize = statSync(logFile).size;
|
|
29
|
+
|
|
30
|
+
watchFile(logFile, { interval: 500 }, () => {
|
|
31
|
+
try {
|
|
32
|
+
const newSize = statSync(logFile).size;
|
|
33
|
+
if (newSize > lastSize) {
|
|
34
|
+
const content = readFileSync(logFile, 'utf-8');
|
|
35
|
+
const newContent = content.slice(lastSize);
|
|
36
|
+
process.stdout.write(newContent);
|
|
37
|
+
lastSize = newSize;
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// File might be in flux
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Keep process alive
|
|
45
|
+
process.on('SIGINT', () => {
|
|
46
|
+
process.exit(0);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { startDaemon, isRunning } from '../daemon.js';
|
|
2
|
+
import { load } from '../config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Start the background daemon
|
|
6
|
+
*/
|
|
7
|
+
export function startCommand(options) {
|
|
8
|
+
const config = load();
|
|
9
|
+
|
|
10
|
+
if (!config.serverUrl || !config.apiKey || !config.userName) {
|
|
11
|
+
console.error('Error: Not configured. Run: snitch init');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (options.foreground) {
|
|
16
|
+
// Run in foreground (for debugging)
|
|
17
|
+
console.log('Running in foreground mode (Ctrl+C to stop)...\n');
|
|
18
|
+
import('../daemon.js').then(({ runDaemonForeground }) => {
|
|
19
|
+
runDaemonForeground();
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result = startDaemon();
|
|
25
|
+
|
|
26
|
+
if (result.success) {
|
|
27
|
+
console.log(`Snitch daemon started (PID: ${result.pid})`);
|
|
28
|
+
console.log(`User: ${config.userName}`);
|
|
29
|
+
console.log(`Server: ${config.serverUrl}`);
|
|
30
|
+
console.log('\nRun "snitch status" to check status');
|
|
31
|
+
console.log('Run "snitch logs" to view logs');
|
|
32
|
+
} else {
|
|
33
|
+
console.error(`Error: ${result.error}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getStatus } from '../daemon.js';
|
|
2
|
+
import { paths } from '../config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Show daemon status
|
|
6
|
+
*/
|
|
7
|
+
export function statusCommand() {
|
|
8
|
+
const status = getStatus();
|
|
9
|
+
|
|
10
|
+
console.log('Snitch Status');
|
|
11
|
+
console.log('=============\n');
|
|
12
|
+
|
|
13
|
+
if (status.running) {
|
|
14
|
+
console.log(`Status: Running (PID: ${status.pid})`);
|
|
15
|
+
} else {
|
|
16
|
+
console.log('Status: Stopped');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log(`Configured: ${status.configured ? 'Yes' : 'No'}`);
|
|
20
|
+
|
|
21
|
+
if (status.config.userName) {
|
|
22
|
+
console.log(`User: ${status.config.userName}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (status.config.serverUrl) {
|
|
26
|
+
console.log(`Server: ${status.config.serverUrl}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(`\nConfig: ${paths.config}`);
|
|
30
|
+
console.log(`Logs: ${paths.log}`);
|
|
31
|
+
console.log(`PID file: ${paths.pid}`);
|
|
32
|
+
|
|
33
|
+
if (!status.running && status.configured) {
|
|
34
|
+
console.log('\nRun "snitch start" to begin tracking activity.');
|
|
35
|
+
} else if (!status.configured) {
|
|
36
|
+
console.log('\nRun "snitch init" to configure.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { stopDaemon, isRunning } from '../daemon.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stop the background daemon
|
|
5
|
+
*/
|
|
6
|
+
export function stopCommand() {
|
|
7
|
+
const result = stopDaemon();
|
|
8
|
+
|
|
9
|
+
if (result.success) {
|
|
10
|
+
console.log('Snitch daemon stopped');
|
|
11
|
+
} else {
|
|
12
|
+
console.error(`Error: ${result.error}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
}
|
package/cli/config.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const SNITCH_DIR = join(homedir(), '.snitch');
|
|
6
|
+
const CONFIG_FILE = join(SNITCH_DIR, 'config.json');
|
|
7
|
+
const LOG_FILE = join(SNITCH_DIR, 'daemon.log');
|
|
8
|
+
const PID_FILE = join(SNITCH_DIR, 'daemon.pid');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Default config schema
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_CONFIG = {
|
|
14
|
+
serverUrl: '',
|
|
15
|
+
apiKey: '',
|
|
16
|
+
userName: '',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ensure ~/.snitch directory exists
|
|
21
|
+
*/
|
|
22
|
+
export function ensureDir() {
|
|
23
|
+
if (!existsSync(SNITCH_DIR)) {
|
|
24
|
+
mkdirSync(SNITCH_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load config from ~/.snitch/config.json
|
|
30
|
+
*/
|
|
31
|
+
export function load() {
|
|
32
|
+
ensureDir();
|
|
33
|
+
if (existsSync(CONFIG_FILE)) {
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
36
|
+
return { ...DEFAULT_CONFIG, ...data };
|
|
37
|
+
} catch {
|
|
38
|
+
return { ...DEFAULT_CONFIG };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { ...DEFAULT_CONFIG };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Save config to ~/.snitch/config.json
|
|
46
|
+
*/
|
|
47
|
+
export function save(config) {
|
|
48
|
+
ensureDir();
|
|
49
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get a specific config value
|
|
54
|
+
*/
|
|
55
|
+
export function get(key) {
|
|
56
|
+
const config = load();
|
|
57
|
+
return config[key];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set a specific config value
|
|
62
|
+
*/
|
|
63
|
+
export function set(key, value) {
|
|
64
|
+
const config = load();
|
|
65
|
+
config[key] = value;
|
|
66
|
+
save(config);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if config is complete (has all required fields)
|
|
71
|
+
*/
|
|
72
|
+
export function isConfigured() {
|
|
73
|
+
const config = load();
|
|
74
|
+
return config.serverUrl && config.apiKey && config.userName;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const paths = {
|
|
78
|
+
dir: SNITCH_DIR,
|
|
79
|
+
config: CONFIG_FILE,
|
|
80
|
+
log: LOG_FILE,
|
|
81
|
+
pid: PID_FILE,
|
|
82
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This is the actual daemon process that runs in the background.
|
|
5
|
+
* It's spawned by daemon.js startDaemon().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync } from 'fs';
|
|
9
|
+
import { paths, load, ensureDir } from './config.js';
|
|
10
|
+
import { ActivityCollector } from './collector.js';
|
|
11
|
+
import { SnitchClient } from './client.js';
|
|
12
|
+
|
|
13
|
+
ensureDir();
|
|
14
|
+
|
|
15
|
+
// Redirect console to log file
|
|
16
|
+
const originalLog = console.log;
|
|
17
|
+
const originalError = console.error;
|
|
18
|
+
|
|
19
|
+
function log(level, ...args) {
|
|
20
|
+
const timestamp = new Date().toISOString();
|
|
21
|
+
const message = `[${timestamp}] [${level}] ${args.join(' ')}\n`;
|
|
22
|
+
try {
|
|
23
|
+
appendFileSync(paths.log, message);
|
|
24
|
+
} catch {
|
|
25
|
+
// Ignore log write errors
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log = (...args) => log('INFO', ...args);
|
|
30
|
+
console.error = (...args) => log('ERROR', ...args);
|
|
31
|
+
|
|
32
|
+
// Main daemon logic
|
|
33
|
+
const config = load();
|
|
34
|
+
|
|
35
|
+
if (!config.serverUrl || !config.apiKey || !config.userName) {
|
|
36
|
+
console.error('Not configured. Exiting.');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log(`Starting snitch daemon for ${config.userName}`);
|
|
41
|
+
console.log(`Server: ${config.serverUrl}`);
|
|
42
|
+
|
|
43
|
+
const client = new SnitchClient();
|
|
44
|
+
|
|
45
|
+
const collector = new ActivityCollector((activity) => {
|
|
46
|
+
console.log(`Activity: session=${activity.sessionId.slice(0, 8)}, repo=${activity.repo || '?'}, events=${activity.events.length}`);
|
|
47
|
+
client.enqueue(activity);
|
|
48
|
+
}, config.userName);
|
|
49
|
+
|
|
50
|
+
collector.start();
|
|
51
|
+
|
|
52
|
+
console.log('Watching for Claude Code activity...');
|
|
53
|
+
|
|
54
|
+
// Graceful shutdown
|
|
55
|
+
process.on('SIGTERM', () => {
|
|
56
|
+
console.log('Received SIGTERM, shutting down...');
|
|
57
|
+
collector.stop();
|
|
58
|
+
process.exit(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
process.on('SIGINT', () => {
|
|
62
|
+
console.log('Received SIGINT, shutting down...');
|
|
63
|
+
collector.stop();
|
|
64
|
+
process.exit(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Keep process alive
|
|
68
|
+
setInterval(() => {
|
|
69
|
+
// Heartbeat - keeps the process running
|
|
70
|
+
}, 60000);
|
package/cli/daemon.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, appendFileSync } from 'fs';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { paths, load, ensureDir } from './config.js';
|
|
6
|
+
import { ActivityCollector } from './collector.js';
|
|
7
|
+
import { SnitchClient } from './client.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if daemon is running
|
|
13
|
+
*/
|
|
14
|
+
export function isRunning() {
|
|
15
|
+
if (!existsSync(paths.pid)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const pid = parseInt(readFileSync(paths.pid, 'utf-8').trim(), 10);
|
|
21
|
+
// Check if process exists
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return pid;
|
|
24
|
+
} catch {
|
|
25
|
+
// Process doesn't exist, clean up stale PID file
|
|
26
|
+
try {
|
|
27
|
+
unlinkSync(paths.pid);
|
|
28
|
+
} catch {}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start daemon in background
|
|
35
|
+
*/
|
|
36
|
+
export function startDaemon() {
|
|
37
|
+
ensureDir();
|
|
38
|
+
|
|
39
|
+
if (isRunning()) {
|
|
40
|
+
return { success: false, error: 'Daemon is already running' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const config = load();
|
|
44
|
+
if (!config.serverUrl || !config.apiKey || !config.userName) {
|
|
45
|
+
return { success: false, error: 'Not configured. Run: snitch init' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Spawn detached process
|
|
49
|
+
const daemonScript = join(__dirname, 'daemon-process.js');
|
|
50
|
+
const out = appendFileSync(paths.log, ''); // Ensure log file exists
|
|
51
|
+
|
|
52
|
+
const child = spawn(process.execPath, [daemonScript], {
|
|
53
|
+
detached: true,
|
|
54
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
55
|
+
env: { ...process.env, SNITCH_DAEMON: '1' },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
child.unref();
|
|
59
|
+
|
|
60
|
+
// Write PID file
|
|
61
|
+
writeFileSync(paths.pid, child.pid.toString());
|
|
62
|
+
|
|
63
|
+
return { success: true, pid: child.pid };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Stop daemon
|
|
68
|
+
*/
|
|
69
|
+
export function stopDaemon() {
|
|
70
|
+
const pid = isRunning();
|
|
71
|
+
|
|
72
|
+
if (!pid) {
|
|
73
|
+
return { success: false, error: 'Daemon is not running' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
process.kill(pid, 'SIGTERM');
|
|
78
|
+
// Clean up PID file
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(paths.pid);
|
|
81
|
+
} catch {}
|
|
82
|
+
return { success: true };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { success: false, error: err.message };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run daemon in foreground (used by daemon-process.js)
|
|
90
|
+
*/
|
|
91
|
+
export function runDaemonForeground() {
|
|
92
|
+
const config = load();
|
|
93
|
+
|
|
94
|
+
console.log(`[daemon] Starting snitch daemon for ${config.userName}`);
|
|
95
|
+
console.log(`[daemon] Server: ${config.serverUrl}`);
|
|
96
|
+
|
|
97
|
+
const client = new SnitchClient();
|
|
98
|
+
|
|
99
|
+
const collector = new ActivityCollector((activity) => {
|
|
100
|
+
console.log(`[daemon] Activity: session=${activity.sessionId.slice(0, 8)}, repo=${activity.repo || '?'}, events=${activity.events.length}`);
|
|
101
|
+
client.enqueue(activity);
|
|
102
|
+
}, config.userName);
|
|
103
|
+
|
|
104
|
+
collector.start();
|
|
105
|
+
|
|
106
|
+
console.log('[daemon] Watching for Claude Code activity...');
|
|
107
|
+
|
|
108
|
+
// Graceful shutdown
|
|
109
|
+
process.on('SIGTERM', () => {
|
|
110
|
+
console.log('[daemon] Shutting down...');
|
|
111
|
+
collector.stop();
|
|
112
|
+
process.exit(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
process.on('SIGINT', () => {
|
|
116
|
+
console.log('[daemon] Shutting down...');
|
|
117
|
+
collector.stop();
|
|
118
|
+
process.exit(0);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get daemon status info
|
|
124
|
+
*/
|
|
125
|
+
export function getStatus() {
|
|
126
|
+
const pid = isRunning();
|
|
127
|
+
const config = load();
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
running: !!pid,
|
|
131
|
+
pid: pid || null,
|
|
132
|
+
configured: !!(config.serverUrl && config.apiKey && config.userName),
|
|
133
|
+
config: {
|
|
134
|
+
serverUrl: config.serverUrl,
|
|
135
|
+
userName: config.userName,
|
|
136
|
+
apiKey: config.apiKey ? '***' : null,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moshi-labs/snitch",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI daemon that reports Claude Code activity to a team server",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"snitch": "./bin/snitch.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"cli/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node bin/snitch.js --help"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"chokidar": "^3.5.3",
|
|
19
|
+
"commander": "^12.1.0"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18.0.0"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/moshi-labs/snitch.git"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"claude",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"team",
|
|
35
|
+
"awareness"
|
|
36
|
+
],
|
|
37
|
+
"author": "Moshi Labs",
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|