@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 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
+ }
@@ -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
+ }