ronds_ai 0.1.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.
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { runCodeRecord, saveCliError } = require('../lib/code_record');
4
+ const { deployHooks } = require('../lib/hooks_deploy');
5
+
6
+ const SUPPORTED_SOURCES = new Set(['claude', 'cursor']);
7
+
8
+ function printUsage() {
9
+ process.stderr.write([
10
+ 'Usage:',
11
+ ' ronds_ai record <tool>',
12
+ ' ronds_ai hooks deploy',
13
+ '',
14
+ 'Supported tools for record:',
15
+ ' claude',
16
+ ' cursor',
17
+ '',
18
+ 'Examples:',
19
+ ' npx ronds_ai record claude',
20
+ ' npx ronds_ai record cursor',
21
+ ' npx ronds_ai hooks deploy',
22
+ ].join('\n'));
23
+ process.stderr.write('\n');
24
+ }
25
+
26
+ async function runHooksCommand(args) {
27
+ const [action] = args;
28
+
29
+ if (action !== 'deploy') {
30
+ throw new Error('Unsupported hooks command');
31
+ }
32
+
33
+ const result = deployHooks(process.cwd());
34
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
35
+ }
36
+
37
+ async function run() {
38
+ const [, , command, ...args] = process.argv;
39
+
40
+ if (command === 'record') {
41
+ const [source] = args;
42
+ const normalizedSource = String(source || '').trim().toLowerCase();
43
+
44
+ if (!SUPPORTED_SOURCES.has(normalizedSource)) {
45
+ throw new Error(`Unsupported record tool: ${source || ''}`);
46
+ }
47
+
48
+ await runCodeRecord(normalizedSource);
49
+ return;
50
+ }
51
+
52
+ if (command === 'hooks') {
53
+ await runHooksCommand(args);
54
+ return;
55
+ }
56
+
57
+ throw new Error(`Unsupported command: ${command || ''}`);
58
+ }
59
+
60
+ run().catch((error) => {
61
+ const [, , command, ...args] = process.argv;
62
+ let savedPath = '';
63
+
64
+ try {
65
+ savedPath = saveCliError(error, { command: command || '', args });
66
+ } catch {
67
+ savedPath = '';
68
+ }
69
+
70
+ printUsage();
71
+ const message = error instanceof Error ? error.stack || error.message : String(error);
72
+ process.stderr.write(`${message}\n`);
73
+ if (savedPath) {
74
+ process.stderr.write(`cli error saved to ${savedPath}\n`);
75
+ }
76
+ process.exitCode = 1;
77
+ });
@@ -0,0 +1,610 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const http = require('http');
5
+ const https = require('https');
6
+ const { execFileSync } = require('child_process');
7
+ const { randomUUID } = require('crypto');
8
+
9
+ const DEFAULT_TIMEOUT_MS = 10000;
10
+ const MAX_ADDED_CONTENT_CHARS = 20000;
11
+ const DEFAULT_HOOK_REPORT_URL = 'http://192.168.2.121:8100/api/v1/ai-code-events';
12
+ const CLAUDE_SUPPORTED_HOOK_EVENT = 'PostToolUse';
13
+ const CLAUDE_SUPPORTED_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
14
+ const CURSOR_SUPPORTED_HOOK_EVENT = 'afterFileEdit';
15
+
16
+ function readStdin() {
17
+ return new Promise((resolve, reject) => {
18
+ let data = '';
19
+ process.stdin.setEncoding('utf8');
20
+ process.stdin.on('data', (chunk) => {
21
+ data += chunk;
22
+ });
23
+ process.stdin.on('end', () => resolve(data));
24
+ process.stdin.on('error', reject);
25
+ });
26
+ }
27
+
28
+ function parseJson(raw, label) {
29
+ try {
30
+ return JSON.parse(raw);
31
+ } catch (error) {
32
+ const detail = error instanceof Error ? error.message : String(error);
33
+ throw new Error(`${label} is not valid JSON: ${detail}`);
34
+ }
35
+ }
36
+
37
+ function normalizeLines(text) {
38
+ return String(text || '').replace(/\r\n/g, '\n').split('\n');
39
+ }
40
+
41
+ function buildLcsMatrix(beforeLines, afterLines) {
42
+ const matrix = Array.from(
43
+ { length: beforeLines.length + 1 },
44
+ () => Array(afterLines.length + 1).fill(0),
45
+ );
46
+
47
+ for (let i = beforeLines.length - 1; i >= 0; i -= 1) {
48
+ for (let j = afterLines.length - 1; j >= 0; j -= 1) {
49
+ if (beforeLines[i] === afterLines[j]) {
50
+ matrix[i][j] = matrix[i + 1][j + 1] + 1;
51
+ } else {
52
+ matrix[i][j] = Math.max(matrix[i + 1][j], matrix[i][j + 1]);
53
+ }
54
+ }
55
+ }
56
+
57
+ return matrix;
58
+ }
59
+
60
+ function diffLineStats(beforeText, afterText) {
61
+ const beforeLines = normalizeLines(beforeText);
62
+ const afterLines = normalizeLines(afterText);
63
+ const matrix = buildLcsMatrix(beforeLines, afterLines);
64
+ const addedLines = [];
65
+
66
+ let added = 0;
67
+ let removed = 0;
68
+ let i = 0;
69
+ let j = 0;
70
+
71
+ while (i < beforeLines.length && j < afterLines.length) {
72
+ if (beforeLines[i] === afterLines[j]) {
73
+ i += 1;
74
+ j += 1;
75
+ continue;
76
+ }
77
+
78
+ if (matrix[i][j + 1] >= matrix[i + 1][j]) {
79
+ if (afterLines[j] !== '') {
80
+ addedLines.push(afterLines[j]);
81
+ added += 1;
82
+ }
83
+ j += 1;
84
+ } else {
85
+ if (beforeLines[i] !== '') {
86
+ removed += 1;
87
+ }
88
+ i += 1;
89
+ }
90
+ }
91
+
92
+ while (j < afterLines.length) {
93
+ if (afterLines[j] !== '') {
94
+ addedLines.push(afterLines[j]);
95
+ added += 1;
96
+ }
97
+ j += 1;
98
+ }
99
+
100
+ while (i < beforeLines.length) {
101
+ if (beforeLines[i] !== '') {
102
+ removed += 1;
103
+ }
104
+ i += 1;
105
+ }
106
+
107
+ let addedContent = addedLines.join('\n');
108
+ if (addedContent.length > MAX_ADDED_CONTENT_CHARS) {
109
+ addedContent = `${addedContent.slice(0, MAX_ADDED_CONTENT_CHARS)}\n... (truncated)`;
110
+ }
111
+
112
+ return { added, removed, addedContent };
113
+ }
114
+
115
+ function findGitRepoRoot(startDir) {
116
+ let currentDir = path.resolve(startDir);
117
+
118
+ while (true) {
119
+ if (fs.existsSync(path.join(currentDir, '.git'))) {
120
+ return currentDir;
121
+ }
122
+
123
+ const parentDir = path.dirname(currentDir);
124
+ if (parentDir === currentDir) {
125
+ return null;
126
+ }
127
+
128
+ currentDir = parentDir;
129
+ }
130
+ }
131
+
132
+ function runGit(repoRoot, args, required = true) {
133
+ try {
134
+ return execFileSync('git', args, {
135
+ cwd: repoRoot,
136
+ encoding: 'utf8',
137
+ stdio: ['ignore', 'pipe', 'pipe'],
138
+ }).trim();
139
+ } catch (error) {
140
+ if (!required) {
141
+ return '';
142
+ }
143
+
144
+ const stderr = error && error.stderr ? String(error.stderr).trim() : String(error);
145
+ throw new Error(`git ${args.join(' ')} failed: ${stderr}`);
146
+ }
147
+ }
148
+
149
+ function extractRepoNameFromRemoteUrl(remoteUrl) {
150
+ const normalizedUrl = String(remoteUrl || '').trim().replace(/[\\/]+$/, '');
151
+ if (!normalizedUrl) {
152
+ return '';
153
+ }
154
+
155
+ const lastSegment = normalizedUrl.split(/[:/]/).pop() || '';
156
+ return lastSegment.replace(/\.git$/i, '').trim();
157
+ }
158
+
159
+ function resolveGitMetadata(repoRoot) {
160
+ const remoteUrl = runGit(repoRoot, ['remote', 'get-url', 'origin'], false);
161
+ const remoteRepoName = extractRepoNameFromRemoteUrl(remoteUrl);
162
+
163
+ return {
164
+ repo_name: remoteRepoName || path.basename(repoRoot),
165
+ remote_url: remoteUrl,
166
+ 'user.name': runGit(repoRoot, ['config', 'user.name'], false),
167
+ 'user.email': runGit(repoRoot, ['config', 'user.email'], false),
168
+ };
169
+ }
170
+
171
+ function normalizeWorkspaceRoot(root) {
172
+ const raw = String(root || '').trim();
173
+ if (!raw) {
174
+ return '';
175
+ }
176
+
177
+ const normalized = raw.replace(/^\/+/, '');
178
+ if (/^[a-zA-Z]:/.test(normalized)) {
179
+ return path.normalize(normalized);
180
+ }
181
+
182
+ return path.normalize(raw);
183
+ }
184
+
185
+ function resolveClaudeAbsoluteFilePath(filePath, payload) {
186
+ const rawPath = String(filePath || '').trim();
187
+ if (!rawPath) {
188
+ return '';
189
+ }
190
+
191
+ if (path.isAbsolute(rawPath)) {
192
+ return path.normalize(rawPath);
193
+ }
194
+
195
+ const candidateBases = [
196
+ payload.cwd,
197
+ payload.workspace && payload.workspace.current_dir,
198
+ payload.workspace && payload.workspace.project_dir,
199
+ process.env.CLAUDE_PROJECT_DIR,
200
+ process.cwd(),
201
+ ].filter(Boolean);
202
+
203
+ for (const base of candidateBases) {
204
+ const candidate = path.resolve(base, rawPath);
205
+ if (fs.existsSync(candidate) || fs.existsSync(path.dirname(candidate))) {
206
+ return candidate;
207
+ }
208
+ }
209
+
210
+ return path.resolve(process.cwd(), rawPath);
211
+ }
212
+
213
+ function resolveCursorAbsoluteFilePath(filePath, payload) {
214
+ const rawPath = String(filePath || '').trim();
215
+ if (!rawPath) {
216
+ return '';
217
+ }
218
+
219
+ if (path.isAbsolute(rawPath)) {
220
+ return path.normalize(rawPath);
221
+ }
222
+
223
+ const workspaceRoots = Array.isArray(payload.workspace_roots)
224
+ ? payload.workspace_roots.map(normalizeWorkspaceRoot)
225
+ : [];
226
+ const candidateBases = [
227
+ ...workspaceRoots,
228
+ process.env.CURSOR_PROJECT_DIR,
229
+ process.cwd(),
230
+ ].filter(Boolean);
231
+
232
+ for (const base of candidateBases) {
233
+ const candidate = path.resolve(base, rawPath);
234
+ if (fs.existsSync(candidate) || fs.existsSync(path.dirname(candidate))) {
235
+ return candidate;
236
+ }
237
+ }
238
+
239
+ return path.resolve(process.cwd(), rawPath);
240
+ }
241
+
242
+ function getGitInfo(filePath) {
243
+ if (!filePath) {
244
+ return { repoRoot: null, git: null, repoRelativePath: '' };
245
+ }
246
+
247
+ const lookupBase = fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()
248
+ ? filePath
249
+ : path.dirname(filePath);
250
+ const repoRoot = findGitRepoRoot(lookupBase);
251
+ if (!repoRoot) {
252
+ return {
253
+ repoRoot: null,
254
+ git: null,
255
+ repoRelativePath: String(filePath).replace(/\\/g, '/'),
256
+ };
257
+ }
258
+
259
+ return {
260
+ repoRoot,
261
+ repoRelativePath: path.relative(repoRoot, filePath).replace(/\\/g, '/'),
262
+ git: resolveGitMetadata(repoRoot),
263
+ };
264
+ }
265
+
266
+ function readWorkerIdConfig(baseDir) {
267
+ if (!baseDir) {
268
+ return '';
269
+ }
270
+
271
+ const configPath = path.join(baseDir, '.ai_config', 'config.json');
272
+ try {
273
+ if (!fs.existsSync(configPath)) {
274
+ return '';
275
+ }
276
+
277
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
278
+ if (typeof parsed.worker_id === 'string' && parsed.worker_id.trim()) {
279
+ return parsed.worker_id.trim();
280
+ }
281
+ } catch {
282
+ return '';
283
+ }
284
+
285
+ return '';
286
+ }
287
+
288
+ function resolveWorkerId(repoRoot) {
289
+ const candidates = [process.cwd(), repoRoot];
290
+ for (const candidate of candidates) {
291
+ const workerId = readWorkerIdConfig(candidate);
292
+ if (workerId) {
293
+ return workerId;
294
+ }
295
+ }
296
+
297
+ return process.env.MCP_TRACKER_WORKER_ID || process.env.WORKER_ID || os.userInfo().username;
298
+ }
299
+
300
+ function buildWriteChange(toolInput) {
301
+ const content = String(toolInput.content || '').replace(/\r\n/g, '\n');
302
+ return {
303
+ operation: 'write',
304
+ changes: {
305
+ added_content: content,
306
+ added: content ? content.split('\n').filter((line) => line !== '').length : 0,
307
+ removed: 0,
308
+ },
309
+ };
310
+ }
311
+
312
+ function buildEditChange(toolInput) {
313
+ const diff = diffLineStats(toolInput.old_string || '', toolInput.new_string || '');
314
+ return {
315
+ operation: 'edit',
316
+ changes: {
317
+ added_content: diff.addedContent,
318
+ added: diff.added,
319
+ removed: diff.removed,
320
+ },
321
+ };
322
+ }
323
+
324
+ function buildMultiEditChange(toolInput) {
325
+ const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
326
+ const aggregate = {
327
+ added_content: [],
328
+ added: 0,
329
+ removed: 0,
330
+ };
331
+
332
+ for (const edit of edits) {
333
+ const diff = diffLineStats(edit && edit.old_string, edit && edit.new_string);
334
+ if (diff.addedContent) {
335
+ aggregate.added_content.push(diff.addedContent);
336
+ }
337
+ aggregate.added += diff.added;
338
+ aggregate.removed += diff.removed;
339
+ }
340
+
341
+ return {
342
+ operation: 'edit',
343
+ changes: {
344
+ added_content: aggregate.added_content.join('\n'),
345
+ added: aggregate.added,
346
+ removed: aggregate.removed,
347
+ },
348
+ };
349
+ }
350
+
351
+ function buildCursorChange(payload) {
352
+ const edits = Array.isArray(payload.edits) ? payload.edits : [];
353
+ const aggregate = {
354
+ added_content: [],
355
+ added: 0,
356
+ removed: 0,
357
+ };
358
+
359
+ for (const edit of edits) {
360
+ const diff = diffLineStats(edit && edit.old_string, edit && edit.new_string);
361
+ if (diff.addedContent) {
362
+ aggregate.added_content.push(diff.addedContent);
363
+ }
364
+ aggregate.added += diff.added;
365
+ aggregate.removed += diff.removed;
366
+ }
367
+
368
+ return {
369
+ operation: 'edit',
370
+ changes: {
371
+ added_content: aggregate.added_content.join('\n'),
372
+ added: aggregate.added,
373
+ removed: aggregate.removed,
374
+ },
375
+ };
376
+ }
377
+
378
+ function buildClaudeEvent(payload, source) {
379
+ const hookEvent = payload.hook_event_name || payload.hookEvent;
380
+ const toolName = payload.tool_name || payload.toolName;
381
+ if (hookEvent !== CLAUDE_SUPPORTED_HOOK_EVENT || !CLAUDE_SUPPORTED_TOOLS.has(toolName)) {
382
+ return null;
383
+ }
384
+
385
+ const toolInput = payload.tool_input || payload.toolInput || {};
386
+ const absoluteFilePath = resolveClaudeAbsoluteFilePath(toolInput.file_path, payload);
387
+ const { repoRoot, git, repoRelativePath } = getGitInfo(absoluteFilePath);
388
+ const workerId = resolveWorkerId(repoRoot);
389
+
390
+ let changeInfo;
391
+ if (toolName === 'Write') {
392
+ changeInfo = buildWriteChange(toolInput);
393
+ } else if (toolName === 'Edit') {
394
+ changeInfo = buildEditChange(toolInput);
395
+ } else {
396
+ changeInfo = buildMultiEditChange(toolInput);
397
+ }
398
+
399
+ return {
400
+ event_id: randomUUID(),
401
+ timestamp: new Date().toISOString(),
402
+ source,
403
+ worker_id: workerId,
404
+ git,
405
+ file: {
406
+ path: repoRelativePath || String(toolInput.file_path || ''),
407
+ operation: changeInfo.operation,
408
+ },
409
+ changes: changeInfo.changes,
410
+ };
411
+ }
412
+
413
+ function buildCursorEvent(payload, source) {
414
+ const hookEvent = payload.hook_event_name || payload.hookEvent;
415
+ if (hookEvent !== CURSOR_SUPPORTED_HOOK_EVENT) {
416
+ return null;
417
+ }
418
+
419
+ const absoluteFilePath = resolveCursorAbsoluteFilePath(payload.file_path, payload);
420
+ const { repoRoot, git, repoRelativePath } = getGitInfo(absoluteFilePath);
421
+ const workerId = resolveWorkerId(repoRoot);
422
+ const changeInfo = buildCursorChange(payload);
423
+
424
+ return {
425
+ event_id: randomUUID(),
426
+ timestamp: new Date().toISOString(),
427
+ source,
428
+ worker_id: workerId,
429
+ git,
430
+ file: {
431
+ path: repoRelativePath || String(payload.file_path || ''),
432
+ operation: changeInfo.operation,
433
+ },
434
+ changes: changeInfo.changes,
435
+ };
436
+ }
437
+
438
+ function loadRequestConfig() {
439
+ const url = process.env.HOOK_REPORT_URL || process.env.CHANGE_REPORT_URL || DEFAULT_HOOK_REPORT_URL;
440
+ const timeoutMs = Number(process.env.HOOK_REPORT_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
441
+ const token = process.env.HOOK_REPORT_TOKEN || '';
442
+
443
+ let extraHeaders = {};
444
+ if (process.env.HOOK_REPORT_HEADERS) {
445
+ extraHeaders = parseJson(process.env.HOOK_REPORT_HEADERS, 'HOOK_REPORT_HEADERS');
446
+ }
447
+
448
+ return { url, timeoutMs, token, extraHeaders };
449
+ }
450
+
451
+ function formatDateStamp(timestamp) {
452
+ const iso = String(timestamp || '');
453
+ const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
454
+ if (match) {
455
+ return `${match[1]}${match[2]}${match[3]}`;
456
+ }
457
+
458
+ const date = new Date();
459
+ const year = String(date.getFullYear());
460
+ const month = String(date.getMonth() + 1).padStart(2, '0');
461
+ const day = String(date.getDate()).padStart(2, '0');
462
+ return `${year}${month}${day}`;
463
+ }
464
+
465
+ function getFailedEventDir() {
466
+ return path.join(os.homedir(), '.ronds_ai', 'failed-events');
467
+ }
468
+
469
+ function getFailedEventFilePath(source, timestamp) {
470
+ const fileName = `${formatDateStamp(timestamp)}-${source}-error.jsonl`;
471
+ return path.join(getFailedEventDir(), fileName);
472
+ }
473
+
474
+ function appendFailedRecord(source, record, timestamp) {
475
+ const failedEventDir = getFailedEventDir();
476
+ fs.mkdirSync(failedEventDir, { recursive: true });
477
+ const filePath = getFailedEventFilePath(source, timestamp);
478
+ fs.appendFileSync(filePath, `${JSON.stringify(record)}\n`, 'utf8');
479
+ return filePath;
480
+ }
481
+
482
+ function saveFailedEvent(event, error) {
483
+ const errorMessage = error instanceof Error ? error.message : String(error || '');
484
+ const record = {
485
+ timestamp: event.timestamp,
486
+ event_id: event.event_id,
487
+ source: event.source,
488
+ error: errorMessage,
489
+ event,
490
+ };
491
+ return appendFailedRecord(event.source, record, event.timestamp);
492
+ }
493
+
494
+ function saveCliError(error, context = {}) {
495
+ const timestamp = new Date().toISOString();
496
+ const errorMessage = error instanceof Error ? error.message : String(error || '');
497
+ const record = {
498
+ timestamp,
499
+ source: 'cli',
500
+ error: errorMessage,
501
+ context,
502
+ };
503
+ return appendFailedRecord('cli', record, timestamp);
504
+ }
505
+
506
+ function sendJsonRequest(event, config) {
507
+ if (process.env.HOOK_REQUEST_DRY_RUN === '1') {
508
+ process.stdout.write(`${JSON.stringify(event, null, 2)}\n`);
509
+ return Promise.resolve({ statusCode: 0, body: 'dry-run', dryRun: true });
510
+ }
511
+
512
+ if (!config.url) {
513
+ return Promise.reject(new Error('Missing request URL'));
514
+ }
515
+
516
+ const target = new URL(config.url);
517
+ const body = JSON.stringify(event);
518
+ const transport = target.protocol === 'https:' ? https : http;
519
+ const headers = {
520
+ 'content-type': 'application/json',
521
+ 'content-length': Buffer.byteLength(body),
522
+ ...config.extraHeaders,
523
+ };
524
+
525
+ if (config.token) {
526
+ headers.authorization = `Bearer ${config.token}`;
527
+ }
528
+
529
+ return new Promise((resolve, reject) => {
530
+ const request = transport.request({
531
+ protocol: target.protocol,
532
+ hostname: target.hostname,
533
+ port: target.port || undefined,
534
+ path: `${target.pathname}${target.search}`,
535
+ method: 'POST',
536
+ headers,
537
+ timeout: config.timeoutMs,
538
+ }, (response) => {
539
+ let responseBody = '';
540
+ response.setEncoding('utf8');
541
+ response.on('data', (chunk) => {
542
+ responseBody += chunk;
543
+ });
544
+ response.on('end', () => {
545
+ const statusCode = response.statusCode || 0;
546
+ if (statusCode >= 200 && statusCode < 300) {
547
+ resolve({ statusCode, body: responseBody });
548
+ return;
549
+ }
550
+
551
+ reject(new Error(`Request failed with status ${statusCode}: ${responseBody}`));
552
+ });
553
+ });
554
+
555
+ request.on('timeout', () => {
556
+ request.destroy(new Error(`Request timed out after ${config.timeoutMs}ms`));
557
+ });
558
+ request.on('error', reject);
559
+ request.write(body);
560
+ request.end();
561
+ });
562
+ }
563
+
564
+ function buildEvent(payload, source) {
565
+ if (source === 'claude') {
566
+ return buildClaudeEvent(payload, source);
567
+ }
568
+
569
+ if (source === 'cursor') {
570
+ return buildCursorEvent(payload, source);
571
+ }
572
+
573
+ throw new Error(`Unsupported source: ${source}`);
574
+ }
575
+
576
+ async function runCodeRecord(source) {
577
+ const raw = (await readStdin()).trim();
578
+ if (!raw) {
579
+ return;
580
+ }
581
+
582
+ const payload = parseJson(raw, 'Hook payload');
583
+ const event = buildEvent(payload, source);
584
+ if (!event) {
585
+ return;
586
+ }
587
+
588
+ const config = loadRequestConfig();
589
+
590
+ try {
591
+ const result = await sendJsonRequest(event, config);
592
+ process.stdout.write(`${JSON.stringify({
593
+ status: result.dryRun ? 'dry-run' : 'sent',
594
+ event_id: event.event_id,
595
+ response_status: result.statusCode,
596
+ })}\n`);
597
+ } catch (error) {
598
+ const savedPath = saveFailedEvent(event, error);
599
+ process.stdout.write(`${JSON.stringify({
600
+ status: 'saved',
601
+ event_id: event.event_id,
602
+ saved_path: savedPath,
603
+ })}\n`);
604
+ }
605
+ }
606
+
607
+ module.exports = {
608
+ runCodeRecord,
609
+ saveCliError,
610
+ };
@@ -0,0 +1,245 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const CURSOR_COMMAND = 'npx ronds_ai@latest record cursor';
5
+ const CLAUDE_COMMAND = 'npx ronds_ai@latest record claude';
6
+ const CURSOR_OLD_COMMAND = 'node .cursor/hooks/cursor_hook_request.cjs';
7
+ const CLAUDE_OLD_COMMAND = 'node .claude/hooks/claude_hook_request.cjs';
8
+ const CLAUDE_MATCHER = 'Edit|Write|MultiEdit';
9
+ const CURSOR_MANAGED_COMMANDS = new Set([
10
+ CURSOR_COMMAND,
11
+ CURSOR_OLD_COMMAND,
12
+ ]);
13
+ const CLAUDE_MANAGED_COMMANDS = new Set([
14
+ CLAUDE_COMMAND,
15
+ CLAUDE_OLD_COMMAND,
16
+ ]);
17
+
18
+ function isPlainObject(value) {
19
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
20
+ }
21
+
22
+ function ensureDir(dirPath) {
23
+ fs.mkdirSync(dirPath, { recursive: true });
24
+ }
25
+
26
+ function readJsonFile(filePath, fallback) {
27
+ if (!fs.existsSync(filePath)) {
28
+ return { data: fallback, exists: false };
29
+ }
30
+
31
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
32
+ if (!raw) {
33
+ return { data: fallback, exists: true };
34
+ }
35
+
36
+ let data;
37
+ try {
38
+ data = JSON.parse(raw);
39
+ } catch (error) {
40
+ const detail = error instanceof Error ? error.message : String(error);
41
+ throw new Error(`Invalid JSON in ${filePath}: ${detail}`);
42
+ }
43
+
44
+ if (!isPlainObject(data)) {
45
+ throw new Error(`Expected top-level object in ${filePath}`);
46
+ }
47
+
48
+ return { data, exists: true };
49
+ }
50
+
51
+ function writeJsonFile(filePath, data) {
52
+ const nextContent = `${JSON.stringify(data, null, 2)}\n`;
53
+ if (fs.existsSync(filePath)) {
54
+ const currentContent = fs.readFileSync(filePath, 'utf8');
55
+ if (currentContent === nextContent) {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ ensureDir(path.dirname(filePath));
61
+ fs.writeFileSync(filePath, nextContent, 'utf8');
62
+ return true;
63
+ }
64
+
65
+ function removeFileIfExists(filePath, removedFiles) {
66
+ if (!fs.existsSync(filePath)) {
67
+ return;
68
+ }
69
+
70
+ fs.rmSync(filePath, { force: true });
71
+ removedFiles.push(filePath);
72
+ }
73
+
74
+ function removeKnownCommands(entries, commands) {
75
+ const input = Array.isArray(entries) ? entries : [];
76
+ return input.filter((entry) => {
77
+ return !(isPlainObject(entry) && entry.type === 'command' && commands.has(entry.command));
78
+ });
79
+ }
80
+
81
+ function ensureCursorHook(config) {
82
+ const next = isPlainObject(config) ? { ...config } : {};
83
+ const hooks = isPlainObject(next.hooks) ? { ...next.hooks } : {};
84
+ const afterFileEdit = Array.isArray(hooks.afterFileEdit) ? hooks.afterFileEdit.slice() : [];
85
+ const filtered = removeKnownCommands(afterFileEdit, CURSOR_MANAGED_COMMANDS);
86
+
87
+ filtered.push({
88
+ type: 'command',
89
+ command: CURSOR_COMMAND,
90
+ });
91
+
92
+ hooks.afterFileEdit = filtered;
93
+ next.version = next.version === undefined ? 1 : next.version;
94
+ next.hooks = hooks;
95
+ return next;
96
+ }
97
+
98
+ function splitClaudeHooks(hooksConfig) {
99
+ const nextHooks = isPlainObject(hooksConfig) ? { ...hooksConfig } : {};
100
+ const postToolUse = Array.isArray(nextHooks.PostToolUse) ? nextHooks.PostToolUse : [];
101
+ const keepEntries = [];
102
+ const desiredHooks = [];
103
+
104
+ for (const entry of postToolUse) {
105
+ if (!isPlainObject(entry)) {
106
+ keepEntries.push(entry);
107
+ continue;
108
+ }
109
+
110
+ if (entry.matcher !== CLAUDE_MATCHER) {
111
+ keepEntries.push(entry);
112
+ continue;
113
+ }
114
+
115
+ const existingHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
116
+ for (const hook of existingHooks) {
117
+ if (!(isPlainObject(hook) && hook.type === 'command' && CLAUDE_MANAGED_COMMANDS.has(hook.command))) {
118
+ desiredHooks.push(hook);
119
+ }
120
+ }
121
+ }
122
+
123
+ desiredHooks.push({
124
+ type: 'command',
125
+ command: CLAUDE_COMMAND,
126
+ });
127
+
128
+ return {
129
+ withoutManagedHook: keepEntries,
130
+ desiredEntry: {
131
+ matcher: CLAUDE_MATCHER,
132
+ hooks: desiredHooks,
133
+ },
134
+ };
135
+ }
136
+
137
+ function ensureClaudeSettings(config) {
138
+ const next = isPlainObject(config) ? { ...config } : {};
139
+ const hooks = isPlainObject(next.hooks) ? { ...next.hooks } : {};
140
+ const { withoutManagedHook, desiredEntry } = splitClaudeHooks(hooks);
141
+
142
+ hooks.PostToolUse = withoutManagedHook.concat([desiredEntry]);
143
+ next.hooks = hooks;
144
+ return next;
145
+ }
146
+
147
+ function removeClaudeHookFromConfig(config) {
148
+ const next = isPlainObject(config) ? { ...config } : {};
149
+ if (!isPlainObject(next.hooks)) {
150
+ return next;
151
+ }
152
+
153
+ const hooks = { ...next.hooks };
154
+ const postToolUse = Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [];
155
+ const cleaned = [];
156
+
157
+ for (const entry of postToolUse) {
158
+ if (!isPlainObject(entry)) {
159
+ cleaned.push(entry);
160
+ continue;
161
+ }
162
+
163
+ if (entry.matcher !== CLAUDE_MATCHER) {
164
+ cleaned.push(entry);
165
+ continue;
166
+ }
167
+
168
+ const existingHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
169
+ const filteredHooks = removeKnownCommands(existingHooks, CLAUDE_MANAGED_COMMANDS);
170
+
171
+ if (filteredHooks.length > 0) {
172
+ cleaned.push({
173
+ ...entry,
174
+ hooks: filteredHooks,
175
+ });
176
+ }
177
+ }
178
+
179
+ if (cleaned.length > 0) {
180
+ hooks.PostToolUse = cleaned;
181
+ } else {
182
+ delete hooks.PostToolUse;
183
+ }
184
+
185
+ if (Object.keys(hooks).length > 0) {
186
+ next.hooks = hooks;
187
+ } else {
188
+ delete next.hooks;
189
+ }
190
+
191
+ return next;
192
+ }
193
+
194
+ function deployHooks(targetDir = process.cwd()) {
195
+ const baseDir = path.resolve(targetDir);
196
+ const removedFiles = [];
197
+ const updatedFiles = [];
198
+ const createdFiles = [];
199
+
200
+ removeFileIfExists(path.join(baseDir, '.cursor', 'hooks', 'cursor_hook_request.cjs'), removedFiles);
201
+ removeFileIfExists(path.join(baseDir, '.claude', 'hooks', 'claude_hook_request.cjs'), removedFiles);
202
+ removeFileIfExists(path.join(baseDir, '.claude', 'hooks', 'claide_hook_request.cjs'), removedFiles);
203
+
204
+ const cursorPath = path.join(baseDir, '.cursor', 'hooks.json');
205
+ const cursorResult = readJsonFile(cursorPath, {});
206
+ const nextCursor = ensureCursorHook(cursorResult.data);
207
+ if (writeJsonFile(cursorPath, nextCursor)) {
208
+ (cursorResult.exists ? updatedFiles : createdFiles).push(cursorPath);
209
+ }
210
+
211
+ const claudeSettingsPath = path.join(baseDir, '.claude', 'settings.json');
212
+ const claudeSettingsResult = readJsonFile(claudeSettingsPath, {});
213
+ const nextClaudeSettings = ensureClaudeSettings(claudeSettingsResult.data);
214
+ if (writeJsonFile(claudeSettingsPath, nextClaudeSettings)) {
215
+ (claudeSettingsResult.exists ? updatedFiles : createdFiles).push(claudeSettingsPath);
216
+ }
217
+
218
+ const localCandidates = [
219
+ path.join(baseDir, '.claude', 'settings.local.json'),
220
+ path.join(baseDir, '.claude', 'setting.local.json'),
221
+ ];
222
+
223
+ for (const localPath of localCandidates) {
224
+ if (!fs.existsSync(localPath)) {
225
+ continue;
226
+ }
227
+
228
+ const localResult = readJsonFile(localPath, {});
229
+ const nextLocal = removeClaudeHookFromConfig(localResult.data);
230
+ if (writeJsonFile(localPath, nextLocal)) {
231
+ updatedFiles.push(localPath);
232
+ }
233
+ }
234
+
235
+ return {
236
+ targetDir: baseDir,
237
+ removedFiles,
238
+ updatedFiles,
239
+ createdFiles,
240
+ };
241
+ }
242
+
243
+ module.exports = {
244
+ deployHooks,
245
+ };
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "ronds_ai",
3
+ "version": "0.1.0",
4
+ "description": "CLI for reporting AI code edit events.",
5
+ "bin": {
6
+ "ronds_ai": "bin/ronds_ai.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "lib"
11
+ ],
12
+ "license": "MIT"
13
+ }