ronds_ai 0.1.23 → 0.1.25

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 CHANGED
@@ -190,7 +190,7 @@ ronds_ai analyze claude
190
190
 
191
191
  行为与 Langfuse Claude Observability Plugin 一致,只采集 transcript 中的 `text`、`tool_use` 和 `tool_result`,不上传 `thinking` block。状态和日志保存在 `~/.ronds_ai/analyze/`。
192
192
 
193
- Langfuse 连接信息已内置,Trace 的 `userId` 使用当前操作系统用户名。可选配置:
193
+ Langfuse 连接信息已内置,Trace 的 `userId` 优先使用当前项目的 `git user.email`,读取不到时回退到当前操作系统用户名。可选配置:
194
194
 
195
195
  - `CC_LANGFUSE_DEBUG=true`:启用详细日志
196
196
  - `CC_LANGFUSE_MAX_CHARS=<正整数>`:单个文本字段的最大字符数,默认 `20000`
package/bin/ronds_ai.js CHANGED
@@ -62,10 +62,10 @@ const SUPPORTED_SOURCES = new Set(['claude', 'cursor', 'codex', 'hermes']);
62
62
 
63
63
  function printUsage() {
64
64
  process.stderr.write([
65
- 'Usage:',
66
- ' ronds_ai record <tool>',
67
- ' ronds_ai analyze claude',
68
- ' ronds_ai check record',
65
+ 'Usage:',
66
+ ' ronds_ai record <tool>',
67
+ ' ronds_ai analyze claude',
68
+ ' ronds_ai check record',
69
69
  ' ronds_ai doctor <tool>',
70
70
  ' ronds_ai hooks deploy [--scope project|user] [--tool cursor|claude|codex]',
71
71
  ' ronds_ai hooks deploy hermes',
@@ -78,8 +78,8 @@ function printUsage() {
78
78
  ' hermes',
79
79
  '',
80
80
  'Examples:',
81
- ' npx ronds_ai@latest record claude',
82
- ' npx ronds_ai@latest analyze claude',
81
+ ' npx ronds_ai@latest record claude',
82
+ ' npx ronds_ai@latest analyze claude',
83
83
  ' npx ronds_ai@latest record cursor',
84
84
  ' npx ronds_ai@latest check record',
85
85
  ' npx ronds_ai@latest doctor claude',
@@ -282,7 +282,7 @@ async function run() {
282
282
 
283
283
  maybeStartHooksAutoSync({ command, args });
284
284
 
285
- if (command === 'record') {
285
+ if (command === 'record') {
286
286
  const [source] = args;
287
287
  const normalizedSource = String(source || '').trim().toLowerCase();
288
288
 
@@ -291,21 +291,21 @@ async function run() {
291
291
  }
292
292
 
293
293
  await runCodeRecord(normalizedSource);
294
- return;
295
- }
296
-
297
- if (command === 'analyze') {
298
- const [source] = args;
299
- if (String(source || '').trim().toLowerCase() !== 'claude') {
300
- throw new Error(`Unsupported analyze tool: ${source || ''}`);
301
- }
302
-
303
- // 仅在 analyze 分支中加载 Node 20+ 的可选 Langfuse 依赖,避免影响旧版 record。
304
- const { runClaudeAnalyze } = require('../lib/analyze_claude');
305
- await runClaudeAnalyze();
306
- return;
307
- }
308
-
294
+ return;
295
+ }
296
+
297
+ if (command === 'analyze') {
298
+ const [source] = args;
299
+ if (String(source || '').trim().toLowerCase() !== 'claude') {
300
+ throw new Error(`Unsupported analyze tool: ${source || ''}`);
301
+ }
302
+
303
+ // 仅在 analyze 分支中加载 Node 20+ 的可选 Langfuse 依赖,避免影响旧版 record。
304
+ const { runClaudeAnalyze } = require('../lib/analyze_claude');
305
+ await runClaudeAnalyze();
306
+ return;
307
+ }
308
+
309
309
  if (command === 'doctor') {
310
310
  const [tool] = args;
311
311
  const normalizedTool = String(tool || '').trim().toLowerCase();
@@ -342,14 +342,28 @@ async function run() {
342
342
  throw new Error(`Unsupported command: ${command || ''}`);
343
343
  }
344
344
 
345
- run().catch((error) => {
346
- if (error && error.code === 'ANALYZE_PREFLIGHT') {
347
- process.stderr.write(`${error.message}\n`);
348
- process.exitCode = 1;
349
- return;
350
- }
351
-
352
- const [, , command, ...args] = process.argv;
345
+ run().catch((error) => {
346
+ if (error && error.code === 'ANALYZE_PREFLIGHT') {
347
+ // 旁路 analyze 预检未通过时静默跳过(如 Node 版本不满足或依赖缺失),避免干扰主流程或弹出警告
348
+ try {
349
+ const fs = require('fs');
350
+ const os = require('os');
351
+ const path = require('path');
352
+ const analyzeDir = path.join(os.homedir(), '.ronds_ai', 'analyze');
353
+ fs.mkdirSync(analyzeDir, { recursive: true });
354
+ fs.appendFileSync(
355
+ path.join(analyzeDir, 'langfuse_hook.log'),
356
+ `${new Date().toISOString()} [INFO] analyze preflight skipped: ${error.message}\n`,
357
+ 'utf8',
358
+ );
359
+ } catch {
360
+ // 忽略日志写入错误
361
+ }
362
+ process.exitCode = 0;
363
+ return;
364
+ }
365
+
366
+ const [, , command, ...args] = process.argv;
353
367
  let savedPath = '';
354
368
 
355
369
  try {
@@ -44,19 +44,99 @@ function assertAnalyzeNodeVersion(version = process.versions.node) {
44
44
  }
45
45
  }
46
46
 
47
+ /**
48
+ * 尝试解析并 require 指定的模块,支持常规 require 和 ~/.ronds_ai/analyze/node_modules 路径。
49
+ */
50
+ function resolveAnalyzeModule(moduleName) {
51
+ try {
52
+ return require(moduleName);
53
+ } catch (error) {
54
+ if (error && error.code !== 'MODULE_NOT_FOUND') {
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ return resolveIsolatedAnalyzeModule(moduleName);
60
+ }
61
+
62
+ /**
63
+ * 只从 analyze 隔离目录解析模块,避免把 npx 临时目录中的依赖误判为已完成预装。
64
+ * @param {string} moduleName - Node.js 模块名
65
+ * @param {string} analyzeDir - analyze 隔离目录
66
+ * @returns {object|null}
67
+ */
68
+ function resolveIsolatedAnalyzeModule(moduleName, analyzeDir = ANALYZE_DIR) {
69
+ const customPath = path.join(analyzeDir, 'node_modules', moduleName);
70
+ try {
71
+ return require(customPath);
72
+ } catch (error) {
73
+ if (error && error.code !== 'MODULE_NOT_FOUND') {
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * 检查 analyze 必需的依赖是否已就绪。
83
+ */
84
+ function areAnalyzeDependenciesInstalled() {
85
+ try {
86
+ const otelSdk = resolveAnalyzeModule('@opentelemetry/sdk-node');
87
+ const langfuseOtel = resolveAnalyzeModule('@langfuse/otel');
88
+ const langfuseTracing = resolveAnalyzeModule('@langfuse/tracing');
89
+ return Boolean(
90
+ otelSdk && otelSdk.NodeSDK
91
+ && langfuseOtel && langfuseOtel.LangfuseSpanProcessor
92
+ && langfuseTracing && typeof langfuseTracing.startObservation === 'function',
93
+ );
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 检查 analyze 隔离目录中的必需依赖是否已就绪。
101
+ * deploy 只能使用此检查,运行时仍可使用普通依赖优先、隔离目录兜底的策略。
102
+ * @param {string} analyzeDir - analyze 隔离目录
103
+ * @returns {boolean}
104
+ */
105
+ function areIsolatedAnalyzeDependenciesInstalled(analyzeDir = ANALYZE_DIR) {
106
+ try {
107
+ const otelSdk = resolveIsolatedAnalyzeModule('@opentelemetry/sdk-node', analyzeDir);
108
+ const langfuseOtel = resolveIsolatedAnalyzeModule('@langfuse/otel', analyzeDir);
109
+ const langfuseTracing = resolveIsolatedAnalyzeModule('@langfuse/tracing', analyzeDir);
110
+ return Boolean(
111
+ otelSdk && otelSdk.NodeSDK
112
+ && langfuseOtel && langfuseOtel.LangfuseSpanProcessor
113
+ && langfuseTracing && typeof langfuseTracing.startObservation === 'function',
114
+ );
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
47
120
  /**
48
121
  * 懒加载 analyze 专用的可选 Langfuse 依赖。
49
122
  */
50
123
  function loadLangfuseSdk() {
51
124
  try {
52
- const { NodeSDK } = require('@opentelemetry/sdk-node');
53
- const { LangfuseSpanProcessor } = require('@langfuse/otel');
54
- const tracing = require('@langfuse/tracing');
125
+ const otelSdk = resolveAnalyzeModule('@opentelemetry/sdk-node');
126
+ const langfuseOtel = resolveAnalyzeModule('@langfuse/otel');
127
+ const langfuseTracing = resolveAnalyzeModule('@langfuse/tracing');
128
+ if (
129
+ !otelSdk || !otelSdk.NodeSDK
130
+ || !langfuseOtel || !langfuseOtel.LangfuseSpanProcessor
131
+ || !langfuseTracing || typeof langfuseTracing.startObservation !== 'function'
132
+ ) {
133
+ throw new Error('Dependencies missing');
134
+ }
55
135
  return {
56
- NodeSDK,
57
- LangfuseSpanProcessor,
58
- startObservation: tracing.startObservation,
59
- propagateAttributes: tracing.propagateAttributes,
136
+ NodeSDK: otelSdk.NodeSDK,
137
+ LangfuseSpanProcessor: langfuseOtel.LangfuseSpanProcessor,
138
+ startObservation: langfuseTracing.startObservation,
139
+ propagateAttributes: langfuseTracing.propagateAttributes,
60
140
  };
61
141
  } catch {
62
142
  throw new AnalyzePreflightError(
@@ -102,11 +182,20 @@ function extractHookContext(payload) {
102
182
  return null;
103
183
  }
104
184
  const hookEventName = payload.hook_event_name || payload.hookEventName || '';
105
- return {
185
+ const context = {
106
186
  sessionId,
107
187
  transcriptPath: resolvedPath,
108
188
  flushDeferredAgentTurns: hookEventName === 'SessionEnd',
109
189
  };
190
+ const projectDir = [
191
+ payload.cwd,
192
+ payload.workspace && payload.workspace.current_dir,
193
+ payload.workspace && payload.workspace.project_dir,
194
+ ].find((value) => typeof value === 'string' && value.trim());
195
+ if (projectDir) {
196
+ context.projectDir = path.resolve(projectDir);
197
+ }
198
+ return context;
110
199
  }
111
200
 
112
201
  /**
@@ -426,10 +515,7 @@ async function processHookContext(context, config, sdkModules) {
426
515
  async function runClaudeAnalyze() {
427
516
  assertAnalyzeNodeVersion();
428
517
  const sdkModules = loadLangfuseSdk();
429
- const config = getAnalyzeConfig();
430
- writeAnalyzeDebug('analyze hook started', config);
431
518
  const raw = await readStdin();
432
- writeAnalyzeDebug(`stdin received ${raw.length} chars`, config);
433
519
  if (!raw.trim()) {
434
520
  return;
435
521
  }
@@ -438,18 +524,23 @@ async function runClaudeAnalyze() {
438
524
  try {
439
525
  payload = JSON.parse(raw);
440
526
  } catch (error) {
527
+ const config = getAnalyzeConfig();
441
528
  writeAnalyzeLog('INFO', `invalid hook payload: ${error.message}`, config);
442
529
  return;
443
530
  }
444
- writeAnalyzeDebug(
445
- `payload top-level keys: ${Object.keys(payload).sort().join(', ')}`,
446
- config,
447
- );
448
531
  const context = extractHookContext(payload);
449
532
  if (!context) {
533
+ const config = getAnalyzeConfig();
450
534
  writeAnalyzeLog('INFO', 'hook payload has no usable session or transcript', config);
451
535
  return;
452
536
  }
537
+ const config = getAnalyzeConfig(context.projectDir);
538
+ writeAnalyzeDebug('analyze hook started', config);
539
+ writeAnalyzeDebug(`stdin received ${raw.length} chars`, config);
540
+ writeAnalyzeDebug(
541
+ `payload top-level keys: ${Object.keys(payload).sort().join(', ')}`,
542
+ config,
543
+ );
453
544
 
454
545
  try {
455
546
  await processHookContext(context, config, sdkModules);
@@ -462,10 +553,15 @@ async function runClaudeAnalyze() {
462
553
  module.exports = {
463
554
  ANALYZE_DIR,
464
555
  AnalyzePreflightError,
556
+ areAnalyzeDependenciesInstalled,
557
+ areIsolatedAnalyzeDependenciesInstalled,
465
558
  assertAnalyzeNodeVersion,
466
559
  extractHookContext,
467
560
  getSessionStateKey,
561
+ loadLangfuseSdk,
468
562
  processHookContext,
469
563
  redactLogMessage,
564
+ resolveAnalyzeModule,
565
+ resolveIsolatedAnalyzeModule,
470
566
  runClaudeAnalyze,
471
567
  };
@@ -1,4 +1,5 @@
1
1
  const os = require('os');
2
+ const { runGit } = require('./git');
2
3
 
3
4
  // Langfuse 连接信息按产品要求从当前 Claude 配置写入源码。
4
5
  const LANGFUSE_PUBLIC_KEY = 'pk-lf-default-001';
@@ -46,15 +47,32 @@ function resolveSystemUsername() {
46
47
  return process.env.USERNAME || process.env.USER || 'unknown';
47
48
  }
48
49
 
50
+ /**
51
+ * 读取当前项目的 Git 邮箱,与 record 上报的 git.user.email 保持一致。
52
+ */
53
+ function resolveGitUserEmail(projectDir) {
54
+ const workingDirectory = typeof projectDir === 'string' && projectDir.trim()
55
+ ? projectDir.trim()
56
+ : process.env.CLAUDE_PROJECT_DIR || process.cwd();
57
+ return runGit(workingDirectory, ['config', 'user.email'], false).toLowerCase();
58
+ }
59
+
60
+ /**
61
+ * 解析 Langfuse 用户标识,优先使用 Git 邮箱并保留系统用户名兜底。
62
+ */
63
+ function resolveAnalyzeUserId(projectDir) {
64
+ return resolveGitUserEmail(projectDir) || resolveSystemUsername();
65
+ }
66
+
49
67
  /**
50
68
  * 返回 analyze 命令使用的固定 Langfuse 配置。
51
69
  */
52
- function getAnalyzeConfig() {
70
+ function getAnalyzeConfig(projectDir) {
53
71
  return {
54
72
  publicKey: LANGFUSE_PUBLIC_KEY,
55
73
  secretKey: LANGFUSE_SECRET_KEY,
56
74
  baseUrl: LANGFUSE_BASE_URL,
57
- userId: resolveSystemUsername(),
75
+ userId: resolveAnalyzeUserId(projectDir),
58
76
  debug: readBooleanOption('CC_LANGFUSE_DEBUG', false),
59
77
  maxChars: readPositiveIntegerOption('CC_LANGFUSE_MAX_CHARS', 20000),
60
78
  skillTags: readBooleanOption('CC_LANGFUSE_SKILL_TAGS', true),
@@ -64,5 +82,7 @@ function getAnalyzeConfig() {
64
82
 
65
83
  module.exports = {
66
84
  getAnalyzeConfig,
85
+ resolveAnalyzeUserId,
86
+ resolveGitUserEmail,
67
87
  resolveSystemUsername,
68
88
  };
@@ -20,11 +20,15 @@ const { spawn } = require('child_process');
20
20
 
21
21
  /** 当前 hooks schema 版本。当用户级 hooks deploy 输出发生变化时 +1。 */
22
22
  // v2: 用户级 Claude settings 新增 Stop/SessionEnd analyze hook。
23
- const HOOKS_SCHEMA_VERSION = 2;
23
+ // v3: 优化 analyze 依赖预装到独立目录并守门。
24
+ const HOOKS_SCHEMA_VERSION = 3;
24
25
 
25
26
  /** sentinel 有效时长:24 小时 */
26
27
  const HOOKS_AUTO_SYNC_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
27
28
 
29
+ /** analyze 依赖安装失败后的重试间隔:24 小时 */
30
+ const ANALYZE_DEPENDENCY_RETRY_TTL_MS = 24 * 60 * 60 * 1000;
31
+
28
32
  /** lock 文件视为 stale 的超时时长:10 分钟 */
29
33
  const STALE_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
30
34
 
@@ -119,12 +123,14 @@ function readHooksAutoSyncState() {
119
123
 
120
124
  /**
121
125
  * 写入 hooks_state.json。
122
- * @param {{ schemaVersion?: number, lastCheckedAt?: number, lastSyncedAt?: number }} state
126
+ * @param {{ schemaVersion?: number, analyzeReady?: boolean, lastAnalyzeAttemptAt?: number, lastCheckedAt?: number, lastSyncedAt?: number }} state
123
127
  */
124
128
  function writeHooksAutoSyncState(state) {
125
129
  ensureAutoSyncDir();
126
130
  const data = {
127
131
  schemaVersion: Number(state.schemaVersion) || 0,
132
+ analyzeReady: state.analyzeReady === true,
133
+ lastAnalyzeAttemptAt: Number(state.lastAnalyzeAttemptAt) || 0,
128
134
  lastCheckedAt: Number(state.lastCheckedAt) || 0,
129
135
  lastSyncedAt: Number(state.lastSyncedAt) || 0,
130
136
  };
@@ -156,9 +162,10 @@ function shouldSkipHooksAutoSync(command) {
156
162
  *
157
163
  * @param {object} state - 从状态文件读取的状态
158
164
  * @param {number} now - Date.now()
165
+ * @param {string} nodeVersion - 当前 Node.js 版本
159
166
  * @returns {'skip' | 'update-timestamp' | 'schedule'}
160
167
  */
161
- function shouldScheduleHooksAutoSync(state, now) {
168
+ function shouldScheduleHooksAutoSync(state, now, nodeVersion = process.versions.node) {
162
169
  const lastCheckedAt = Number(state.lastCheckedAt || 0);
163
170
 
164
171
  // 仍在 TTL 窗口内,跳过
@@ -168,8 +175,15 @@ function shouldScheduleHooksAutoSync(state, now) {
168
175
 
169
176
  const schemaVersion = Number(state.schemaVersion || 0);
170
177
 
171
- // 版本已是最新,仅更新时间戳
178
+ // schema 已更新但 analyze 依赖未就绪时,按独立间隔重试安装。
172
179
  if (schemaVersion >= HOOKS_SCHEMA_VERSION) {
180
+ const nodeMajor = Number(String(nodeVersion || '').replace(/^v/i, '').split('.')[0]);
181
+ if (nodeMajor >= 20 && state.analyzeReady !== true) {
182
+ const lastAnalyzeAttemptAt = Number(state.lastAnalyzeAttemptAt || 0);
183
+ if (now - lastAnalyzeAttemptAt >= ANALYZE_DEPENDENCY_RETRY_TTL_MS) {
184
+ return 'schedule';
185
+ }
186
+ }
173
187
  return 'update-timestamp';
174
188
  }
175
189
 
@@ -232,6 +246,8 @@ function maybeStartHooksAutoSync({ command, args } = {}) {
232
246
  if (decision === 'update-timestamp') {
233
247
  writeHooksAutoSyncState({
234
248
  schemaVersion: Number(state.schemaVersion || 0),
249
+ analyzeReady: state.analyzeReady === true,
250
+ lastAnalyzeAttemptAt: Number(state.lastAnalyzeAttemptAt || 0),
235
251
  lastCheckedAt: now,
236
252
  lastSyncedAt: Number(state.lastSyncedAt || 0),
237
253
  });
@@ -324,7 +340,7 @@ function releaseHooksAutoSyncLock(fd) {
324
340
  * 此函数由后台子进程(ronds_ai internal hooks auto-sync)调用。
325
341
  * 不向 stdout 输出,错误写入本地日志文件。
326
342
  */
327
- function runHooksAutoSync() {
343
+ function runHooksAutoSync(options = {}) {
328
344
  const lock = acquireHooksAutoSyncLock();
329
345
  if (!lock) return; // 已有进程在同步
330
346
 
@@ -332,14 +348,23 @@ function runHooksAutoSync() {
332
348
 
333
349
  try {
334
350
  const { deployHooks, deployHermesHook } = require('./hooks_deploy');
351
+ const previousState = readHooksAutoSyncState();
335
352
 
336
- deployHooks(os.homedir(), { scope: 'user' });
353
+ const deployResult = deployHooks(os.homedir(), { scope: 'user' });
337
354
  deployHermesHook();
338
355
 
356
+ const nodeVersion = options.nodeVersion || process.versions.node;
357
+ const nodeMajor = Number(String(nodeVersion || '').replace(/^v/i, '').split('.')[0]);
358
+ const analyzeRequired = nodeMajor >= 20;
359
+ const analyzeReady = Boolean(deployResult?.analyze?.installed);
360
+ const syncComplete = !analyzeRequired || analyzeReady;
361
+
339
362
  writeHooksAutoSyncState({
340
363
  schemaVersion: HOOKS_SCHEMA_VERSION,
364
+ analyzeReady,
365
+ lastAnalyzeAttemptAt: analyzeRequired ? now : Number(previousState.lastAnalyzeAttemptAt || 0),
341
366
  lastCheckedAt: now,
342
- lastSyncedAt: now,
367
+ lastSyncedAt: syncComplete ? now : Number(previousState.lastSyncedAt || 0),
343
368
  });
344
369
  touchAutoSyncSentinel(now);
345
370
  } catch (error) {
@@ -382,6 +407,7 @@ function saveHooksAutoSyncError(error) {
382
407
  // ---------------------------------------------------------------------------
383
408
 
384
409
  module.exports = {
410
+ ANALYZE_DEPENDENCY_RETRY_TTL_MS,
385
411
  HOOKS_SCHEMA_VERSION,
386
412
  maybeStartHooksAutoSync,
387
413
  runHooksAutoSync,
@@ -1,8 +1,13 @@
1
- const fs = require('fs');
1
+ const { execFileSync } = require('child_process');
2
+ const fs = require('fs');
2
3
  const os = require('os');
3
4
  const path = require('path');
4
5
  const commentJson = require('comment-json');
5
6
  const yaml = require('js-yaml');
7
+ const {
8
+ ANALYZE_DIR,
9
+ areIsolatedAnalyzeDependenciesInstalled,
10
+ } = require('./analyze_claude');
6
11
 
7
12
  const CURSOR_COMMAND = 'npx ronds_ai@latest record cursor';
8
13
  const CLAUDE_COMMAND = 'npx ronds_ai@latest record claude';
@@ -269,7 +274,7 @@ function splitClaudeAnalyzeEntries(entries) {
269
274
  * 在 Claude settings 中确保 Stop/SessionEnd 的 analyze hook 条目。
270
275
  * 只管理命令完全等于 CLAUDE_ANALYZE_COMMAND 的 hook,保留用户自定义条目。
271
276
  */
272
- function ensureClaudeAnalyzeHooks(config) {
277
+ function ensureClaudeAnalyzeHooks(config) {
273
278
  const next = isPlainObject(config) ? { ...config } : {};
274
279
  const hooks = isPlainObject(next.hooks) ? { ...next.hooks } : {};
275
280
 
@@ -286,8 +291,59 @@ function ensureClaudeAnalyzeHooks(config) {
286
291
  }
287
292
 
288
293
  next.hooks = hooks;
289
- return next;
290
- }
294
+ return next;
295
+ }
296
+
297
+ /**
298
+ * 从 Claude settings 的 Stop/SessionEnd 中移除本工具管理的 analyze hook。
299
+ * 仅删除命令完全相等的 command hook,并保留用户自定义 entry 与命令。
300
+ * @param {object} config - Claude settings 配置
301
+ * @returns {object}
302
+ */
303
+ function removeClaudeAnalyzeHooks(config) {
304
+ const next = isPlainObject(config) ? { ...config } : {};
305
+ if (!isPlainObject(next.hooks)) {
306
+ return next;
307
+ }
308
+
309
+ const hooks = { ...next.hooks };
310
+ for (const eventName of CLAUDE_ANALYZE_EVENTS) {
311
+ const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
312
+ const cleanedEntries = [];
313
+
314
+ for (const entry of entries) {
315
+ if (!isPlainObject(entry) || !Array.isArray(entry.hooks)) {
316
+ cleanedEntries.push(entry);
317
+ continue;
318
+ }
319
+
320
+ const filteredHooks = entry.hooks.filter(
321
+ (hook) => !(isPlainObject(hook)
322
+ && hook.type === 'command'
323
+ && hook.command === CLAUDE_ANALYZE_COMMAND),
324
+ );
325
+
326
+ if (filteredHooks.length === entry.hooks.length) {
327
+ cleanedEntries.push(entry);
328
+ } else if (filteredHooks.length > 0) {
329
+ cleanedEntries.push({ ...entry, hooks: filteredHooks });
330
+ }
331
+ }
332
+
333
+ if (cleanedEntries.length > 0) {
334
+ hooks[eventName] = cleanedEntries;
335
+ } else {
336
+ delete hooks[eventName];
337
+ }
338
+ }
339
+
340
+ if (Object.keys(hooks).length > 0) {
341
+ next.hooks = hooks;
342
+ } else {
343
+ delete next.hooks;
344
+ }
345
+ return next;
346
+ }
291
347
 
292
348
  function ensureCodexHooksFeatureFlag(toml) {
293
349
  const newline = toml.includes('\r\n') ? '\r\n' : '\n';
@@ -584,14 +640,70 @@ function deployCursorHook(cursorPath, result) {
584
640
  recordWriteResult(cursorPath, cursorResult.exists, changed, result);
585
641
  }
586
642
 
643
+ /**
644
+ * 确保 analyze 所需的依赖在 ~/.ronds_ai/analyze/ 中安装就绪。
645
+ * 成功或已存在返回 true;安装失败返回 false。
646
+ */
647
+ function ensureAnalyzeDependencies(options = {}) {
648
+ const analyzeDir = options.analyzeDir || ANALYZE_DIR;
649
+ const areDependenciesInstalled = options.areDependenciesInstalled
650
+ || areIsolatedAnalyzeDependenciesInstalled;
651
+
652
+ if (areDependenciesInstalled(analyzeDir)) {
653
+ return true;
654
+ }
655
+
656
+ try {
657
+ ensureDir(analyzeDir);
658
+ if (typeof options.installDependencies === 'function') {
659
+ options.installDependencies(analyzeDir);
660
+ return areDependenciesInstalled(analyzeDir);
661
+ }
662
+
663
+ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
664
+ const packages = [
665
+ '@langfuse/otel@^5.9.1',
666
+ '@langfuse/tracing@^5.9.1',
667
+ '@opentelemetry/sdk-node@^0.221.0',
668
+ ];
669
+ execFileSync(
670
+ npmCommand,
671
+ [
672
+ 'install',
673
+ '--no-audit',
674
+ '--no-fund',
675
+ '--prefix',
676
+ analyzeDir,
677
+ ...packages,
678
+ ],
679
+ {
680
+ stdio: 'ignore',
681
+ timeout: 60000,
682
+ windowsHide: true,
683
+ },
684
+ );
685
+ return areDependenciesInstalled(analyzeDir);
686
+ } catch {
687
+ return false;
688
+ }
689
+ }
690
+
587
691
  /**
588
692
  * 判断本次部署是否写入 Claude analyze hook。
589
693
  * 不满足条件时把原因写入 result.analyze.reason 并返回 false。
590
694
  */
591
- function shouldDeployClaudeAnalyze(result) {
592
- const nodeMajor = getNodeMajorVersion();
593
- if (nodeMajor < 20) {
594
- result.analyze.reason = `analyze requires Node.js 20 or newer; current is ${process.versions.node || 'unknown'}`;
695
+ function shouldDeployClaudeAnalyze(result, options = {}) {
696
+ const nodeVersion = options.nodeVersion || process.versions.node;
697
+ const nodeMajor = getNodeMajorVersion(nodeVersion);
698
+ if (nodeMajor < 20) {
699
+ result.analyze.reason = `analyze requires Node.js 20 or newer; current is ${nodeVersion || 'unknown'}`;
700
+ return false;
701
+ }
702
+
703
+ const ensureDependencies = options.ensureAnalyzeDependencies || ensureAnalyzeDependencies;
704
+ const dependenciesReady = ensureDependencies(options.dependencyOptions);
705
+ if (!dependenciesReady) {
706
+ result.analyze.reason = 'analyze dependencies failed to install or are unavailable';
595
707
  return false;
596
708
  }
597
709
 
@@ -602,8 +714,10 @@ function deployClaudeSettings(claudeSettingsPath, result, analyzeDeployed) {
602
714
  const claudeSettingsResult = readJsonFile(claudeSettingsPath, {});
603
715
  let nextClaudeSettings = ensureClaudeSettings(claudeSettingsResult.data);
604
716
 
605
- if (analyzeDeployed) {
606
- nextClaudeSettings = ensureClaudeAnalyzeHooks(nextClaudeSettings);
717
+ if (analyzeDeployed) {
718
+ nextClaudeSettings = ensureClaudeAnalyzeHooks(nextClaudeSettings);
719
+ } else {
720
+ nextClaudeSettings = removeClaudeAnalyzeHooks(nextClaudeSettings);
607
721
  }
608
722
 
609
723
  const changed = writeJsonFile(claudeSettingsPath, nextClaudeSettings);
@@ -683,7 +797,7 @@ function deployHooks(targetDir = process.cwd(), options = {}) {
683
797
  }
684
798
 
685
799
  if (!options.tool || options.tool === 'claude') {
686
- const analyzeDeployed = shouldDeployClaudeAnalyze(result);
800
+ const analyzeDeployed = shouldDeployClaudeAnalyze(result, options.analyzeRuntime);
687
801
  deployClaudeSettings(paths.claudeSettingsPath, result, analyzeDeployed);
688
802
  if (analyzeDeployed) {
689
803
  result.analyze.installed = true;
@@ -787,6 +901,9 @@ function deployHermesHook() {
787
901
  module.exports = {
788
902
  deployHooks,
789
903
  deployHermesHook,
790
- ensureClaudeAnalyzeHooks,
791
- getNodeMajorVersion,
904
+ ensureAnalyzeDependencies,
905
+ ensureClaudeAnalyzeHooks,
906
+ getNodeMajorVersion,
907
+ removeClaudeAnalyzeHooks,
908
+ shouldDeployClaudeAnalyze,
792
909
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ronds_ai",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "CLI for reporting AI code edit events.",
5
5
  "bin": {
6
6
  "ronds_ai": "bin/ronds_ai.js"