ronds_ai 0.1.20 → 0.1.21

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
@@ -286,6 +286,30 @@ hooks_auto_accept: true
286
286
  - `removedFiles`
287
287
  - `skipped`: 被跳过的工具及原因(仅在 project scope 下出现)
288
288
 
289
+ ### Hooks Auto Sync(自动后台同步)
290
+
291
+ CLI 会**自动在后台检查并更新用户级 hooks 配置**,无需用户每次手动执行 `hooks deploy --scope user`。
292
+
293
+ 工作方式:
294
+
295
+ 1. 运行任意 CLI 命令时,进行轻量检查(多数情况下只读取一次 sentinel 文件的修改时间)
296
+ 2. 如果距离上次检查超过 24 小时,判断 hooks schema 版本是否落后
297
+ 3. 版本落后时,后台启动子进程自动执行 `hooks deploy --scope user` 和 `deploy hermes`
298
+ 4. 当前命令不等待后台同步完成,也不受后台同步失败的影响
299
+ 5. `record` 高频路径使用 sentinel fast path,额外成本约等于一次文件 stat
300
+
301
+ 可通过环境变量禁用:
302
+
303
+ ```bash
304
+ RONDS_AI_DISABLE_HOOKS_AUTO_SYNC=1
305
+ ```
306
+
307
+ 注意:
308
+
309
+ - auto-sync 只维护**用户级** hooks,不影响项目级配置
310
+ - 后台同步失败时,错误记录到 `~/.ronds_ai/hooks_auto_sync_error.log`,不污染当前命令输出
311
+ - 首次安装 hooks 仍建议执行 `ronds_ai hooks deploy --scope user`
312
+
289
313
  ### `skills install`
290
314
 
291
315
  下载一个技能包并安装到 Claude / Codex / Cursor 对应的技能目录。
package/bin/ronds_ai.js CHANGED
@@ -11,6 +11,7 @@ const {
11
11
  } = require('../lib/check_record');
12
12
  const { runDoctor } = require('../lib/doctor');
13
13
  const { deployHooks, deployHermesHook } = require('../lib/hooks_deploy');
14
+ const { maybeStartHooksAutoSync, runHooksAutoSync } = require('../lib/hooks_auto_sync');
14
15
  const { promptForText, promptYesNo } = require('../lib/skills_prompt');
15
16
 
16
17
  function writeWorkerSummary(result) {
@@ -260,9 +261,25 @@ async function runSkillsCommand(args) {
260
261
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
261
262
  }
262
263
 
264
+ /**
265
+ * 内部命令分发。当前只支持 hooks auto-sync。
266
+ */
267
+ async function runInternalCommand(args) {
268
+ const [subcommand, ...subargs] = args;
269
+
270
+ if (subcommand === 'hooks' && subargs[0] === 'auto-sync') {
271
+ runHooksAutoSync();
272
+ return;
273
+ }
274
+
275
+ throw new Error('Unsupported internal command');
276
+ }
277
+
263
278
  async function run() {
264
279
  const [, , command, ...args] = process.argv;
265
280
 
281
+ maybeStartHooksAutoSync({ command, args });
282
+
266
283
  if (command === 'record') {
267
284
  const [source] = args;
268
285
  const normalizedSource = String(source || '').trim().toLowerCase();
@@ -303,6 +320,11 @@ async function run() {
303
320
  return;
304
321
  }
305
322
 
323
+ if (command === 'internal') {
324
+ await runInternalCommand(args);
325
+ return;
326
+ }
327
+
306
328
  throw new Error(`Unsupported command: ${command || ''}`);
307
329
  }
308
330
 
@@ -0,0 +1,397 @@
1
+ /**
2
+ * hooks_auto_sync.js
3
+ *
4
+ * 用户级 Hooks 自动同步模块。
5
+ *
6
+ * 提供轻量的前台检查(maybeStartHooksAutoSync)和后端子进程同步(runHooksAutoSync),
7
+ * 让 CLI 命令能无感自动维护用户级 hooks 配置(Claude/Cursor/Hermes/Codex)。
8
+ *
9
+ * 快速路径:sentinel 文件 mtime + TTL,避免高频命令(如 record)反复读取状态 JSON。
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const { spawn } = require('child_process');
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // 常量
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** 当前 hooks schema 版本。当用户级 hooks deploy 输出发生变化时 +1。 */
22
+ const HOOKS_SCHEMA_VERSION = 1;
23
+
24
+ /** sentinel 有效时长:24 小时 */
25
+ const HOOKS_AUTO_SYNC_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
26
+
27
+ /** lock 文件视为 stale 的超时时长:10 分钟 */
28
+ const STALE_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // 路径解析
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function getAutoSyncDir() {
35
+ return path.join(os.homedir(), '.ronds_ai');
36
+ }
37
+
38
+ function getStateFilePath() {
39
+ return path.join(getAutoSyncDir(), 'hooks_state.json');
40
+ }
41
+
42
+ function getSentinelFilePath() {
43
+ return path.join(getAutoSyncDir(), 'hooks_auto_sync_sentinel');
44
+ }
45
+
46
+ function getLockFilePath() {
47
+ return path.join(getAutoSyncDir(), 'hooks_auto_sync.lock');
48
+ }
49
+
50
+ function getErrorLogPath() {
51
+ return path.join(getAutoSyncDir(), 'hooks_auto_sync_error.log');
52
+ }
53
+
54
+ function ensureAutoSyncDir() {
55
+ const dir = getAutoSyncDir();
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ return dir;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Sentinel fast path(步骤 3)
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * 快速跳过检查——sentinel 文件存在且 mtime 在 TTL 内。
66
+ * @param {number} now - Date.now()
67
+ * @returns {boolean}
68
+ */
69
+ function shouldFastSkipAutoSync(now) {
70
+ try {
71
+ const stat = fs.statSync(getSentinelFilePath());
72
+ return now - stat.mtimeMs < HOOKS_AUTO_SYNC_CHECK_TTL_MS;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 更新 sentinel 文件的 mtime。
80
+ * 文件不存在则创建空文件。
81
+ * @param {number} now - Date.now()
82
+ */
83
+ function touchAutoSyncSentinel(now) {
84
+ ensureAutoSyncDir();
85
+ const sentinelPath = getSentinelFilePath();
86
+ const atime = Math.floor(now / 1000);
87
+ const mtime = Math.floor(now / 1000);
88
+ try {
89
+ fs.utimesSync(sentinelPath, atime, mtime);
90
+ } catch {
91
+ // 文件不存在,创建空文件
92
+ fs.writeFileSync(sentinelPath, '');
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // 状态文件读写(步骤 4)
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * 读取 hooks_state.json。
102
+ * 文件不存在、内容为空、JSON 解析失败或顶层非对象时返回空对象。
103
+ * @returns {object}
104
+ */
105
+ function readHooksAutoSyncState() {
106
+ try {
107
+ const content = fs.readFileSync(getStateFilePath(), 'utf-8');
108
+ if (!content.trim()) return {};
109
+ const parsed = JSON.parse(content);
110
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
111
+ return {};
112
+ }
113
+ return parsed;
114
+ } catch {
115
+ return {};
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 写入 hooks_state.json。
121
+ * @param {{ schemaVersion?: number, lastCheckedAt?: number, lastSyncedAt?: number }} state
122
+ */
123
+ function writeHooksAutoSyncState(state) {
124
+ ensureAutoSyncDir();
125
+ const data = {
126
+ schemaVersion: Number(state.schemaVersion) || 0,
127
+ lastCheckedAt: Number(state.lastCheckedAt) || 0,
128
+ lastSyncedAt: Number(state.lastSyncedAt) || 0,
129
+ };
130
+ fs.writeFileSync(getStateFilePath(), JSON.stringify(data, null, 2) + '\n');
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // 命令过滤逻辑(步骤 5)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * 判断当前命令是否应完全跳过 auto-sync。
139
+ * @param {string} command - 命令名称
140
+ * @returns {boolean}
141
+ */
142
+ function shouldSkipHooksAutoSync(command) {
143
+ if (process.env.RONDS_AI_DISABLE_HOOKS_AUTO_SYNC === '1') return true;
144
+ if (command === 'hooks' || command === 'internal') return true;
145
+ if (!command) return true;
146
+ return false;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // TTL 与版本判断(步骤 6)
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * 根据状态和当前时间判断是否需要调度后台同步。
155
+ *
156
+ * @param {object} state - 从状态文件读取的状态
157
+ * @param {number} now - Date.now()
158
+ * @returns {'skip' | 'update-timestamp' | 'schedule'}
159
+ */
160
+ function shouldScheduleHooksAutoSync(state, now) {
161
+ const lastCheckedAt = Number(state.lastCheckedAt || 0);
162
+
163
+ // 仍在 TTL 窗口内,跳过
164
+ if (now - lastCheckedAt < HOOKS_AUTO_SYNC_CHECK_TTL_MS) {
165
+ return 'skip';
166
+ }
167
+
168
+ const schemaVersion = Number(state.schemaVersion || 0);
169
+
170
+ // 版本已是最新,仅更新时间戳
171
+ if (schemaVersion >= HOOKS_SCHEMA_VERSION) {
172
+ return 'update-timestamp';
173
+ }
174
+
175
+ // 版本落后,需要调度同步
176
+ return 'schedule';
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // 后台进程启动(步骤 7)
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /**
184
+ * 启动后台子进程执行用户级 hooks deploy。
185
+ * 使用 process.execPath + require.resolve 确保:
186
+ * - Windows 上不需 shell,detach 可靠
187
+ * - 与当前进程使用相同的 Node 版本
188
+ * - 指向本地已安装的代码(版本一致)
189
+ */
190
+ function spawnHooksAutoSyncProcess() {
191
+ try {
192
+ const cliEntry = require.resolve('../bin/ronds_ai.js');
193
+ const child = spawn(process.execPath, [cliEntry, 'internal', 'hooks', 'auto-sync'], {
194
+ detached: true,
195
+ stdio: 'ignore',
196
+ windowsHide: true,
197
+ });
198
+ child.on('error', saveHooksAutoSyncError);
199
+ child.unref();
200
+ } catch (error) {
201
+ saveHooksAutoSyncError(error);
202
+ }
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // maybeStartHooksAutoSync(步骤 8)
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * 轻量前台检查函数。
211
+ *
212
+ * 所有 CLI 命令在分发前均可调用此函数,它会在绝大多数调用中快速返回。
213
+ *
214
+ * @param {{ command: string, args?: string[] }} params
215
+ */
216
+ function maybeStartHooksAutoSync({ command, args } = {}) {
217
+ try {
218
+ if (shouldSkipHooksAutoSync(command)) return;
219
+
220
+ const now = Date.now();
221
+ if (shouldFastSkipAutoSync(now)) return;
222
+
223
+ const state = readHooksAutoSyncState();
224
+ const decision = shouldScheduleHooksAutoSync(state, now);
225
+
226
+ if (decision === 'skip') {
227
+ touchAutoSyncSentinel(now);
228
+ return;
229
+ }
230
+
231
+ if (decision === 'update-timestamp') {
232
+ writeHooksAutoSyncState({
233
+ schemaVersion: Number(state.schemaVersion || 0),
234
+ lastCheckedAt: now,
235
+ lastSyncedAt: Number(state.lastSyncedAt || 0),
236
+ });
237
+ touchAutoSyncSentinel(now);
238
+ return;
239
+ }
240
+
241
+ // decision === 'schedule'
242
+ touchAutoSyncSentinel(now);
243
+ spawnHooksAutoSyncProcess();
244
+ } catch (error) {
245
+ saveHooksAutoSyncError(error);
246
+ }
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Lock 文件管理(步骤 9)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * 获取 auto-sync 全局锁。
255
+ * 使用原子创建('wx')防止并发。
256
+ *
257
+ * @returns {number | null} lock 文件描述符,获取失败返回 null
258
+ */
259
+ function acquireHooksAutoSyncLock() {
260
+ const lockPath = getLockFilePath();
261
+ ensureAutoSyncDir();
262
+
263
+ // 首次尝试创建
264
+ try {
265
+ const fd = fs.openSync(lockPath, 'wx');
266
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, time: Date.now() }) + '\n');
267
+ return fd;
268
+ } catch (err) {
269
+ if (err.code !== 'EEXIST') return null;
270
+ }
271
+
272
+ // 文件已存在,检查是否 stale
273
+ try {
274
+ const stat = fs.statSync(lockPath);
275
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_TIMEOUT_MS) {
276
+ fs.unlinkSync(lockPath);
277
+ // 重试一次
278
+ try {
279
+ const fd = fs.openSync(lockPath, 'wx');
280
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, time: Date.now() }) + '\n');
281
+ return fd;
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+ } catch {
287
+ // stat 失败,尝试直接创建
288
+ try {
289
+ const fd = fs.openSync(lockPath, 'wx');
290
+ return fd;
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * 释放 auto-sync 全局锁。
301
+ * @param {number} fd - 文件描述符
302
+ */
303
+ function releaseHooksAutoSyncLock(fd) {
304
+ try {
305
+ fs.closeSync(fd);
306
+ } catch {
307
+ // 忽略关闭失败
308
+ }
309
+ try {
310
+ fs.unlinkSync(getLockFilePath());
311
+ } catch {
312
+ // 忽略删除失败
313
+ }
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // 后台同步函数 runHooksAutoSync(步骤 10)
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /**
321
+ * 执行用户级 hooks auto-sync。
322
+ *
323
+ * 此函数由后台子进程(ronds_ai internal hooks auto-sync)调用。
324
+ * 不向 stdout 输出,错误写入本地日志文件。
325
+ */
326
+ function runHooksAutoSync() {
327
+ const lock = acquireHooksAutoSyncLock();
328
+ if (!lock) return; // 已有进程在同步
329
+
330
+ const now = Date.now();
331
+
332
+ try {
333
+ const { deployHooks, deployHermesHook } = require('./hooks_deploy');
334
+
335
+ deployHooks(os.homedir(), { scope: 'user' });
336
+ deployHermesHook();
337
+
338
+ writeHooksAutoSyncState({
339
+ schemaVersion: HOOKS_SCHEMA_VERSION,
340
+ lastCheckedAt: now,
341
+ lastSyncedAt: now,
342
+ });
343
+ touchAutoSyncSentinel(now);
344
+ } catch (error) {
345
+ saveHooksAutoSyncError(error);
346
+ } finally {
347
+ releaseHooksAutoSyncLock(lock);
348
+ }
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // 错误记录
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /**
356
+ * 将 auto-sync 错误写入本地日志文件。
357
+ * @param {Error | string} error
358
+ */
359
+ function saveHooksAutoSyncError(error) {
360
+ try {
361
+ ensureAutoSyncDir();
362
+ const timestamp = new Date().toISOString();
363
+ const message = error instanceof Error ? error.stack || error.message : String(error);
364
+ const entry = [
365
+ `--- ${timestamp} ---`,
366
+ `HOOKS_SCHEMA_VERSION=${HOOKS_SCHEMA_VERSION}`,
367
+ `node=${process.version}`,
368
+ `platform=${process.platform}`,
369
+ `error=${message}`,
370
+ '',
371
+ ].join('\n');
372
+
373
+ fs.appendFileSync(getErrorLogPath(), entry, 'utf-8');
374
+ } catch {
375
+ // 错误记录失败时不做任何事,避免连锁异常
376
+ }
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // 导出
381
+ // ---------------------------------------------------------------------------
382
+
383
+ module.exports = {
384
+ HOOKS_SCHEMA_VERSION,
385
+ maybeStartHooksAutoSync,
386
+ runHooksAutoSync,
387
+
388
+ // 以下为测试和内部使用导出的函数
389
+ shouldSkipHooksAutoSync,
390
+ shouldFastSkipAutoSync,
391
+ shouldScheduleHooksAutoSync,
392
+ touchAutoSyncSentinel,
393
+ readHooksAutoSyncState,
394
+ writeHooksAutoSyncState,
395
+ acquireHooksAutoSyncLock,
396
+ releaseHooksAutoSyncLock,
397
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ronds_ai",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "CLI for reporting AI code edit events.",
5
5
  "bin": {
6
6
  "ronds_ai": "bin/ronds_ai.js"