vibeusage 0.2.17 → 0.2.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeusage",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
package/src/cli.js CHANGED
@@ -57,6 +57,7 @@ function printHelp() {
57
57
  ' - --dry-run previews changes without writing files.',
58
58
  ' - optional: --link-code <code> skips browser login when provided by Dashboard.',
59
59
  ' - Every Code notify installs when ~/.code/config.toml exists.',
60
+ ' - OpenClaw hook auto-links when OpenClaw is installed (requires gateway restart).',
60
61
  ' - auto sync waits for a device token.',
61
62
  ' - optional: VIBEUSAGE_DASHBOARD_URL or --dashboard-url for hosted landing.',
62
63
  ' - sync parses ~/.codex/sessions/**/rollout-*.jsonl and ~/.code/sessions/**/rollout-*.jsonl, then uploads token deltas.',
@@ -22,6 +22,7 @@ const {
22
22
  isGeminiHookConfigured
23
23
  } = require('../lib/gemini-config');
24
24
  const { resolveOpencodeConfigDir, upsertOpencodePlugin, isOpencodePluginInstalled } = require('../lib/opencode-config');
25
+ const { installOpenclawHook, probeOpenclawHookState } = require('../lib/openclaw-hook');
25
26
  const { beginBrowserAuth, openInBrowser } = require('../lib/browser-auth');
26
27
  const {
27
28
  issueDeviceTokenWithPassword,
@@ -185,7 +186,7 @@ function renderWelcome() {
185
186
  DIVIDER,
186
187
  '',
187
188
  'This tool will:',
188
- ' - Analyze your local AI CLI configurations (Codex, Every Code, Claude, Gemini, Opencode)',
189
+ ' - Analyze your local AI CLI configurations (Codex, Every Code, Claude, Gemini, Opencode, OpenClaw)',
189
190
  ' - Set up lightweight hooks to track your flow state',
190
191
  ' - Link your device to your VibeScore account',
191
192
  '',
@@ -336,6 +337,7 @@ function buildIntegrationTargets({ home, trackerDir, notifyPath }) {
336
337
  const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
337
338
 
338
339
  return {
340
+ trackerDir,
339
341
  codexConfigPath,
340
342
  codeConfigPath,
341
343
  notifyOriginalPath,
@@ -406,6 +408,34 @@ async function applyIntegrationSetup({ home, trackerDir, notifyPath, notifyOrigi
406
408
  summary.push({ label: 'Opencode Plugin', status: opencodeResult?.changed ? 'installed' : 'set', detail: 'Plugin installed' });
407
409
  }
408
410
 
411
+ const openclawBefore = await probeOpenclawHookState({ home, trackerDir, env: process.env });
412
+ const openclawInstall = await installOpenclawHook({ home, trackerDir, packageName: 'vibeusage', env: process.env });
413
+ if (openclawInstall?.skippedReason === 'openclaw-cli-missing') {
414
+ summary.push({ label: 'OpenClaw Hook', status: 'skipped', detail: 'OpenClaw CLI not found' });
415
+ } else if (openclawInstall?.skippedReason === 'openclaw-hooks-install-failed') {
416
+ summary.push({
417
+ label: 'OpenClaw Hook',
418
+ status: 'skipped',
419
+ detail: `Install failed${openclawInstall.error ? `: ${openclawInstall.error}` : ''}`
420
+ });
421
+ } else if (openclawInstall?.skippedReason === 'openclaw-config-unreadable') {
422
+ summary.push({
423
+ label: 'OpenClaw Hook',
424
+ status: 'skipped',
425
+ detail: openclawInstall.error ? `OpenClaw config unreadable: ${openclawInstall.error}` : 'OpenClaw config unreadable'
426
+ });
427
+ } else if (openclawInstall?.configured) {
428
+ summary.push({
429
+ label: 'OpenClaw Hook',
430
+ status: openclawBefore?.configured ? 'set' : 'installed',
431
+ detail: openclawBefore?.configured
432
+ ? 'Hook already linked'
433
+ : 'Hook linked (restart OpenClaw gateway to activate)'
434
+ });
435
+ } else {
436
+ summary.push({ label: 'OpenClaw Hook', status: 'skipped', detail: 'OpenClaw hook unavailable' });
437
+ }
438
+
409
439
  const codeProbe = await probeFile(context.codeConfigPath);
410
440
  if (codeProbe.exists) {
411
441
  const result = await upsertEveryCodeNotify({
@@ -427,6 +457,7 @@ async function applyIntegrationSetup({ home, trackerDir, notifyPath, notifyOrigi
427
457
 
428
458
  async function previewIntegrations({ context }) {
429
459
  const summary = [];
460
+ const home = os.homedir();
430
461
 
431
462
  const codexProbe = await probeFile(context.codexConfigPath);
432
463
  if (codexProbe.exists) {
@@ -484,6 +515,25 @@ async function previewIntegrations({ context }) {
484
515
  detail: opencodeDetail
485
516
  });
486
517
 
518
+ const openclawState = await probeOpenclawHookState({ home, trackerDir: context.trackerDir, env: process.env });
519
+ if (openclawState?.skippedReason === 'openclaw-config-missing') {
520
+ summary.push({ label: 'OpenClaw Hook', status: 'skipped', detail: 'OpenClaw config not found' });
521
+ } else if (openclawState?.skippedReason === 'openclaw-config-unreadable') {
522
+ summary.push({
523
+ label: 'OpenClaw Hook',
524
+ status: 'skipped',
525
+ detail: openclawState.error ? `OpenClaw config unreadable: ${openclawState.error}` : 'OpenClaw config unreadable'
526
+ });
527
+ } else {
528
+ summary.push({
529
+ label: 'OpenClaw Hook',
530
+ status: openclawState?.configured ? 'set' : 'installed',
531
+ detail: openclawState?.configured
532
+ ? 'Hook already linked'
533
+ : 'Will link hook (restart OpenClaw gateway to activate)'
534
+ });
535
+ }
536
+
487
537
  const codeProbe = await probeFile(context.codeConfigPath);
488
538
  if (codeProbe.exists) {
489
539
  const existing = await readEveryCodeNotify(context.codeConfigPath);
@@ -15,6 +15,7 @@ const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require('../lib/
15
15
  const { collectLocalSubscriptions } = require('../lib/subscriptions');
16
16
  const { normalizeState: normalizeUploadState } = require('../lib/upload-throttle');
17
17
  const { collectTrackerDiagnostics } = require('../lib/diagnostics');
18
+ const { probeOpenclawHookState } = require('../lib/openclaw-hook');
18
19
  const { resolveTrackerPaths } = require('../lib/tracker-paths');
19
20
 
20
21
  async function cmdStatus(argv = []) {
@@ -74,6 +75,7 @@ async function cmdStatus(argv = []) {
74
75
  hookCommand: geminiHookCommand
75
76
  });
76
77
  const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
78
+ const openclawHookState = await probeOpenclawHookState({ home, trackerDir, env: process.env });
77
79
 
78
80
  const lastUpload = uploadThrottle.lastSuccessMs
79
81
  ? parseEpochMsToIso(uploadThrottle.lastSuccessMs)
@@ -123,6 +125,7 @@ async function cmdStatus(argv = []) {
123
125
  `- Claude hooks: ${claudeHookConfigured ? 'set' : 'unset'}`,
124
126
  `- Gemini hooks: ${geminiHookConfigured ? 'set' : 'unset'}`,
125
127
  `- Opencode plugin: ${opencodePluginConfigured ? 'set' : 'unset'}`,
128
+ `- OpenClaw hook: ${openclawHookState?.configured ? 'set' : 'unset'}`,
126
129
  ...subscriptionLines,
127
130
  ''
128
131
  ]
@@ -70,15 +70,13 @@ async function cmdSync(argv) {
70
70
  const opencodeStorageDir = path.join(opencodeHome, 'storage');
71
71
 
72
72
  // OpenClaw hook integration: allow a hook to request incremental parsing for a single session jsonl.
73
- // When present, we skip all other sources to keep hook-triggered sync fast and deterministic.
73
+ // We still parse all regular sources so model/source attribution stays complete (e.g. Kimi sessions).
74
74
  const openclawSignal = opts.fromOpenclaw ? resolveOpenclawSignal({ home, env: process.env }) : null;
75
75
 
76
- const sources = openclawSignal
77
- ? []
78
- : [
79
- { source: 'codex', sessionsDir: path.join(codexHome, 'sessions') },
80
- { source: 'every-code', sessionsDir: path.join(codeHome, 'sessions') }
81
- ];
76
+ const sources = [
77
+ { source: 'codex', sessionsDir: path.join(codexHome, 'sessions') },
78
+ { source: 'every-code', sessionsDir: path.join(codeHome, 'sessions') }
79
+ ];
82
80
 
83
81
  const rolloutFiles = [];
84
82
  const seenSessions = new Set();
@@ -125,9 +123,9 @@ async function cmdSync(argv) {
125
123
  });
126
124
  }
127
125
 
128
- const claudeFiles = openclawSignal ? [] : await listClaudeProjectFiles(claudeProjectsDir);
126
+ const claudeFiles = await listClaudeProjectFiles(claudeProjectsDir);
129
127
  let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
130
- if (!openclawSignal && claudeFiles.length > 0) {
128
+ if (claudeFiles.length > 0) {
131
129
  if (progress?.enabled) {
132
130
  progress.start(`Parsing Claude ${renderBar(0)} 0/${formatNumber(claudeFiles.length)} files | buckets 0`);
133
131
  }
@@ -149,9 +147,9 @@ async function cmdSync(argv) {
149
147
  });
150
148
  }
151
149
 
152
- const geminiFiles = openclawSignal ? [] : await listGeminiSessionFiles(geminiTmpDir);
150
+ const geminiFiles = await listGeminiSessionFiles(geminiTmpDir);
153
151
  let geminiResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
154
- if (!openclawSignal && geminiFiles.length > 0) {
152
+ if (geminiFiles.length > 0) {
155
153
  if (progress?.enabled) {
156
154
  progress.start(`Parsing Gemini ${renderBar(0)} 0/${formatNumber(geminiFiles.length)} files | buckets 0`);
157
155
  }
@@ -173,9 +171,9 @@ async function cmdSync(argv) {
173
171
  });
174
172
  }
175
173
 
176
- const opencodeFiles = openclawSignal ? [] : await listOpencodeMessageFiles(opencodeStorageDir);
174
+ const opencodeFiles = await listOpencodeMessageFiles(opencodeStorageDir);
177
175
  let opencodeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
178
- if (!openclawSignal && opencodeFiles.length > 0) {
176
+ if (opencodeFiles.length > 0) {
179
177
  if (progress?.enabled) {
180
178
  progress.start(`Parsing Opencode ${renderBar(0)} 0/${formatNumber(opencodeFiles.length)} files | buckets 0`);
181
179
  }
@@ -11,6 +11,7 @@ const {
11
11
  removeGeminiHook
12
12
  } = require('../lib/gemini-config');
13
13
  const { resolveOpencodeConfigDir, removeOpencodePlugin } = require('../lib/opencode-config');
14
+ const { removeOpenclawHookConfig } = require('../lib/openclaw-hook');
14
15
  const { resolveTrackerPaths } = require('../lib/tracker-paths');
15
16
 
16
17
  async function cmdUninstall(argv) {
@@ -61,6 +62,7 @@ async function cmdUninstall(argv) {
61
62
  const opencodeRemove = opencodeConfigExists
62
63
  ? await removeOpencodePlugin({ configDir: opencodeConfigDir })
63
64
  : { removed: false, skippedReason: 'config-missing' };
65
+ const openclawHookRemove = await removeOpenclawHookConfig({ home, trackerDir, env: process.env });
64
66
 
65
67
  // Remove installed notify handler.
66
68
  await fs.unlink(notifyPath).catch(() => {});
@@ -112,6 +114,11 @@ async function cmdUninstall(argv) {
112
114
  ? '- Opencode plugin: skipped (unexpected content)'
113
115
  : '- Opencode plugin: skipped'
114
116
  : `- Opencode plugin: skipped (${opencodeConfigDir} not found)`,
117
+ openclawHookRemove?.removed
118
+ ? `- OpenClaw hook removed: ${openclawHookRemove.openclawConfigPath}`
119
+ : openclawHookRemove?.skippedReason === 'openclaw-config-missing'
120
+ ? '- OpenClaw hook: skipped (openclaw config not found)'
121
+ : '- OpenClaw hook: no change',
115
122
  opts.purge ? `- Purged: ${path.join(home, '.vibeusage')}` : '- Purge: skipped (use --purge)',
116
123
  ''
117
124
  ].join('\n')
@@ -13,6 +13,7 @@ const {
13
13
  } = require('./gemini-config');
14
14
  const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require('./opencode-config');
15
15
  const { normalizeState: normalizeUploadState } = require('./upload-throttle');
16
+ const { probeOpenclawHookState } = require('./openclaw-hook');
16
17
  const { resolveTrackerPaths } = require('./tracker-paths');
17
18
 
18
19
  async function collectTrackerDiagnostics({
@@ -68,6 +69,7 @@ async function collectTrackerDiagnostics({
68
69
  hookCommand: geminiHookCommand
69
70
  });
70
71
  const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
72
+ const openclawHookState = await probeOpenclawHookState({ home, trackerDir, env: process.env });
71
73
 
72
74
  const lastSuccessAt = uploadThrottle.lastSuccessMs ? new Date(uploadThrottle.lastSuccessMs).toISOString() : null;
73
75
  const autoRetryAt = parseEpochMsToIso(autoRetry?.retryAtMs);
@@ -117,7 +119,10 @@ async function collectTrackerDiagnostics({
117
119
  every_code_notify: everyCodeNotify,
118
120
  claude_hook_configured: claudeHookConfigured,
119
121
  gemini_hook_configured: geminiHookConfigured,
120
- opencode_plugin_configured: opencodePluginConfigured
122
+ opencode_plugin_configured: opencodePluginConfigured,
123
+ openclaw_hook_configured: Boolean(openclawHookState?.configured),
124
+ openclaw_hook_linked: Boolean(openclawHookState?.linked),
125
+ openclaw_hook_enabled: Boolean(openclawHookState?.enabled)
121
126
  },
122
127
  upload: {
123
128
  last_success_at: lastSuccessAt,
package/src/lib/doctor.js CHANGED
@@ -305,7 +305,8 @@ function buildDiagnosticsChecks(diagnostics) {
305
305
  notify.every_code_notify_configured ||
306
306
  notify.claude_hook_configured ||
307
307
  notify.gemini_hook_configured ||
308
- notify.opencode_plugin_configured
308
+ notify.opencode_plugin_configured ||
309
+ notify.openclaw_hook_configured
309
310
  );
310
311
 
311
312
  checks.push({
@@ -0,0 +1,358 @@
1
+ const os = require('node:os');
2
+ const path = require('node:path');
3
+ const fs = require('node:fs/promises');
4
+ const fssync = require('node:fs');
5
+ const cp = require('node:child_process');
6
+
7
+ const OPENCLAW_HOOK_NAME = 'vibeusage-openclaw-sync';
8
+ const OPENCLAW_HOOK_DIRNAME = 'openclaw-hook';
9
+
10
+ function resolveOpenclawHookPaths({ home = os.homedir(), trackerDir, env = process.env } = {}) {
11
+ if (!trackerDir) throw new Error('trackerDir is required');
12
+
13
+ const openclawConfigPath =
14
+ normalizeString(env.OPENCLAW_CONFIG_PATH) || path.join(home, '.openclaw', 'openclaw.json');
15
+
16
+ const openclawHome =
17
+ normalizeString(env.VIBEUSAGE_OPENCLAW_HOME) ||
18
+ normalizeString(env.OPENCLAW_STATE_DIR) ||
19
+ path.join(home, '.openclaw');
20
+
21
+ const hookDir = path.join(trackerDir, OPENCLAW_HOOK_DIRNAME);
22
+ const hookEntryDir = path.join(hookDir, OPENCLAW_HOOK_NAME);
23
+
24
+ return {
25
+ hookName: OPENCLAW_HOOK_NAME,
26
+ hookDir,
27
+ hookEntryDir,
28
+ openclawConfigPath,
29
+ openclawHome
30
+ };
31
+ }
32
+
33
+ async function installOpenclawHook({ home = os.homedir(), trackerDir, packageName = 'vibeusage', env = process.env } = {}) {
34
+ const paths = resolveOpenclawHookPaths({ home, trackerDir, env });
35
+
36
+ await ensureOpenclawHookFiles({
37
+ hookDir: paths.hookDir,
38
+ trackerDir,
39
+ packageName,
40
+ openclawHome: paths.openclawHome
41
+ });
42
+
43
+ const installResult = runOpenclawCli(['hooks', 'install', '--link', paths.hookDir], env);
44
+ if (installResult.skippedReason) {
45
+ return { configured: false, ...paths, ...installResult };
46
+ }
47
+
48
+ const state = await probeOpenclawHookState({ home, trackerDir, env });
49
+ return {
50
+ configured: state.configured,
51
+ changed: /Linked hook path:/i.test(installResult.stdout || ''),
52
+ ...paths,
53
+ stdout: installResult.stdout,
54
+ stderr: installResult.stderr,
55
+ code: installResult.code
56
+ };
57
+ }
58
+
59
+ async function ensureOpenclawHookFiles({ hookDir, trackerDir, packageName = 'vibeusage', openclawHome } = {}) {
60
+ if (!hookDir || !trackerDir) throw new Error('hookDir and trackerDir are required');
61
+
62
+ const hookEntryDir = path.join(hookDir, OPENCLAW_HOOK_NAME);
63
+ await fs.mkdir(hookEntryDir, { recursive: true });
64
+
65
+ const hookMdPath = path.join(hookEntryDir, 'HOOK.md');
66
+ const handlerPath = path.join(hookEntryDir, 'handler.js');
67
+
68
+ await fs.writeFile(hookMdPath, buildHookMarkdown(), 'utf8');
69
+ await fs.writeFile(
70
+ handlerPath,
71
+ buildHookHandler({ trackerDir, packageName, openclawHome: openclawHome || path.join(os.homedir(), '.openclaw') }),
72
+ 'utf8'
73
+ );
74
+ }
75
+
76
+ async function probeOpenclawHookState({ home = os.homedir(), trackerDir, env = process.env } = {}) {
77
+ const paths = resolveOpenclawHookPaths({ home, trackerDir, env });
78
+ const { openclawConfigPath, hookDir, hookEntryDir, hookName } = paths;
79
+
80
+ const hookFilesReady =
81
+ fssync.existsSync(path.join(hookEntryDir, 'HOOK.md')) && fssync.existsSync(path.join(hookEntryDir, 'handler.js'));
82
+
83
+ let cfg = null;
84
+ try {
85
+ const raw = await fs.readFile(openclawConfigPath, 'utf8');
86
+ cfg = JSON.parse(raw);
87
+ } catch (err) {
88
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
89
+ return {
90
+ configured: false,
91
+ enabled: false,
92
+ linked: false,
93
+ hookFilesReady,
94
+ skippedReason: 'openclaw-config-missing',
95
+ ...paths
96
+ };
97
+ }
98
+ return {
99
+ configured: false,
100
+ enabled: false,
101
+ linked: false,
102
+ hookFilesReady,
103
+ skippedReason: 'openclaw-config-unreadable',
104
+ error: err?.message || String(err),
105
+ ...paths
106
+ };
107
+ }
108
+
109
+ const enabled = Boolean(cfg?.hooks?.internal?.entries?.[hookName]?.enabled);
110
+ const extraDirs = Array.isArray(cfg?.hooks?.internal?.load?.extraDirs) ? cfg.hooks.internal.load.extraDirs : [];
111
+ const normalizedHookDir = path.resolve(hookDir);
112
+ const linked = extraDirs.some((entry) => path.resolve(String(entry || '')) === normalizedHookDir);
113
+
114
+ return {
115
+ configured: enabled && linked,
116
+ enabled,
117
+ linked,
118
+ hookFilesReady,
119
+ ...paths
120
+ };
121
+ }
122
+
123
+ async function removeOpenclawHookConfig({ home = os.homedir(), trackerDir, env = process.env } = {}) {
124
+ const paths = resolveOpenclawHookPaths({ home, trackerDir, env });
125
+ const { openclawConfigPath, hookDir, hookName } = paths;
126
+
127
+ let cfg;
128
+ try {
129
+ cfg = JSON.parse(await fs.readFile(openclawConfigPath, 'utf8'));
130
+ } catch (err) {
131
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
132
+ return { removed: false, skippedReason: 'openclaw-config-missing', ...paths };
133
+ }
134
+ return {
135
+ removed: false,
136
+ skippedReason: 'openclaw-config-unreadable',
137
+ error: err?.message || String(err),
138
+ ...paths
139
+ };
140
+ }
141
+
142
+ let changed = false;
143
+ const hooks = cfg?.hooks;
144
+ const internal = hooks?.internal;
145
+
146
+ if (internal?.entries && Object.prototype.hasOwnProperty.call(internal.entries, hookName)) {
147
+ delete internal.entries[hookName];
148
+ changed = true;
149
+ if (Object.keys(internal.entries).length === 0) delete internal.entries;
150
+ }
151
+
152
+ if (internal?.load && Array.isArray(internal.load.extraDirs)) {
153
+ const before = internal.load.extraDirs;
154
+ const target = path.resolve(hookDir);
155
+ const after = before.filter((entry) => path.resolve(String(entry || '')) !== target);
156
+ if (after.length !== before.length) {
157
+ internal.load.extraDirs = after;
158
+ changed = true;
159
+ if (after.length === 0) delete internal.load.extraDirs;
160
+ if (Object.keys(internal.load).length === 0) delete internal.load;
161
+ }
162
+ }
163
+
164
+ if (internal?.installs && typeof internal.installs === 'object') {
165
+ const installs = internal.installs;
166
+ if (Object.prototype.hasOwnProperty.call(installs, hookName)) {
167
+ delete installs[hookName];
168
+ changed = true;
169
+ }
170
+
171
+ const target = path.resolve(hookDir);
172
+ for (const [id, entry] of Object.entries(installs)) {
173
+ const sourcePath = normalizeString(entry?.sourcePath);
174
+ const installPath = normalizeString(entry?.installPath);
175
+ if (
176
+ (sourcePath && path.resolve(sourcePath) === target) ||
177
+ (installPath && path.resolve(installPath) === target)
178
+ ) {
179
+ delete installs[id];
180
+ changed = true;
181
+ }
182
+ }
183
+
184
+ if (Object.keys(installs).length === 0) delete internal.installs;
185
+ }
186
+
187
+ if (internal && Object.keys(internal).length === 0) {
188
+ delete hooks.internal;
189
+ changed = true;
190
+ }
191
+ if (hooks && Object.keys(hooks).length === 0) {
192
+ delete cfg.hooks;
193
+ changed = true;
194
+ }
195
+
196
+ if (changed) {
197
+ await fs.writeFile(openclawConfigPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
198
+ }
199
+
200
+ await fs.rm(hookDir, { recursive: true, force: true }).catch(() => {});
201
+
202
+ return { removed: changed, ...paths };
203
+ }
204
+
205
+ function runOpenclawCli(args, env = process.env) {
206
+ let res;
207
+ try {
208
+ res = cp.spawnSync('openclaw', args, {
209
+ env,
210
+ encoding: 'utf8',
211
+ timeout: 30_000
212
+ });
213
+ } catch (err) {
214
+ return {
215
+ code: 1,
216
+ skippedReason: err?.code === 'ENOENT' ? 'openclaw-cli-missing' : 'openclaw-cli-error',
217
+ error: err?.message || String(err),
218
+ stdout: '',
219
+ stderr: ''
220
+ };
221
+ }
222
+
223
+ if (res.error?.code === 'ENOENT') {
224
+ return {
225
+ code: 1,
226
+ skippedReason: 'openclaw-cli-missing',
227
+ error: res.error.message,
228
+ stdout: res.stdout || '',
229
+ stderr: res.stderr || ''
230
+ };
231
+ }
232
+
233
+ if ((res.status || 0) !== 0) {
234
+ return {
235
+ code: Number(res.status || 1),
236
+ skippedReason: 'openclaw-hooks-install-failed',
237
+ error: (res.stderr || res.stdout || '').trim() || 'openclaw hooks install failed',
238
+ stdout: res.stdout || '',
239
+ stderr: res.stderr || ''
240
+ };
241
+ }
242
+
243
+ return {
244
+ code: 0,
245
+ stdout: res.stdout || '',
246
+ stderr: res.stderr || ''
247
+ };
248
+ }
249
+
250
+ function buildHookMarkdown() {
251
+ return `---
252
+ name: ${OPENCLAW_HOOK_NAME}
253
+ description: "Trigger vibeusage sync when OpenClaw sessions roll over"
254
+ metadata:
255
+ { "openclaw": { "emoji": "📈", "events": ["command:new", "command:stop"], "requires": { "bins": ["node"] } } }
256
+ ---
257
+
258
+ # VibeUsage OpenClaw Sync Hook
259
+
260
+ Triggers non-blocking 'vibeusage sync --auto --from-openclaw' runs when OpenClaw command events indicate a session rollover/stop.
261
+ `;
262
+ }
263
+
264
+ function buildHookHandler({ trackerDir, packageName = 'vibeusage', openclawHome }) {
265
+ const trackerBinPath = path.join(trackerDir, 'app', 'bin', 'tracker.js');
266
+ const fallbackPkg = packageName || 'vibeusage';
267
+ const safeOpenclawHome = openclawHome || path.join(os.homedir(), '.openclaw');
268
+
269
+ return `'use strict';\n` +
270
+ `const fs = require('node:fs');\n` +
271
+ `const path = require('node:path');\n` +
272
+ `const cp = require('node:child_process');\n` +
273
+ `const trackerDir = ${JSON.stringify(trackerDir)};\n` +
274
+ `const trackerBinPath = ${JSON.stringify(trackerBinPath)};\n` +
275
+ `const fallbackPkg = ${JSON.stringify(fallbackPkg)};\n` +
276
+ `const openclawHome = ${JSON.stringify(safeOpenclawHome)};\n` +
277
+ `const throttlePath = path.join(trackerDir, 'openclaw.sync.throttle');\n` +
278
+ `const depsMarkerPath = path.join(trackerDir, 'app', 'node_modules', '@insforge', 'sdk', 'package.json');\n` +
279
+ `const THROTTLE_MS = 15_000;\n` +
280
+ `\n` +
281
+ `module.exports = async function handler(event) {\n` +
282
+ ` try {\n` +
283
+ ` if (!event || event.type !== 'command') return;\n` +
284
+ ` if (event.action !== 'new' && event.action !== 'stop') return;\n` +
285
+ `\n` +
286
+ ` const sessionKey = normalize(event.sessionKey);\n` +
287
+ ` const agentId = parseAgentId(sessionKey);\n` +
288
+ ` if (!agentId) return;\n` +
289
+ `\n` +
290
+ ` const sessionId = resolveSessionId(event);\n` +
291
+ ` if (!sessionId) return;\n` +
292
+ `\n` +
293
+ ` const now = Date.now();\n` +
294
+ ` let last = 0;\n` +
295
+ ` try { last = Number(fs.readFileSync(throttlePath, 'utf8')) || 0; } catch (_) {}\n` +
296
+ ` if (now - last < THROTTLE_MS) return;\n` +
297
+ ` try {\n` +
298
+ ` fs.mkdirSync(trackerDir, { recursive: true });\n` +
299
+ ` fs.writeFileSync(throttlePath, String(now), 'utf8');\n` +
300
+ ` } catch (_) {}\n` +
301
+ `\n` +
302
+ ` const env = {\n` +
303
+ ` ...process.env,\n` +
304
+ ` VIBEUSAGE_OPENCLAW_AGENT_ID: agentId,\n` +
305
+ ` VIBEUSAGE_OPENCLAW_PREV_SESSION_ID: sessionId,\n` +
306
+ ` VIBEUSAGE_OPENCLAW_HOME: openclawHome\n` +
307
+ ` };\n` +
308
+ `\n` +
309
+ ` const hasLocalRuntime = fs.existsSync(trackerBinPath);\n` +
310
+ ` const hasLocalDeps = fs.existsSync(depsMarkerPath);\n` +
311
+ ` const cmd = hasLocalRuntime && hasLocalDeps\n` +
312
+ ` ? [process.execPath, trackerBinPath, 'sync', '--auto', '--from-openclaw']\n` +
313
+ ` : ['npx', '--yes', fallbackPkg, 'sync', '--auto', '--from-openclaw'];\n` +
314
+ `\n` +
315
+ ` const child = cp.spawn(cmd[0], cmd.slice(1), { detached: true, stdio: 'ignore', env });\n` +
316
+ ` child.unref();\n` +
317
+ ` } catch (_) {}\n` +
318
+ `};\n` +
319
+ `\n` +
320
+ `function normalize(v) {\n` +
321
+ ` if (typeof v !== 'string') return null;\n` +
322
+ ` const s = v.trim();\n` +
323
+ ` return s.length > 0 ? s : null;\n` +
324
+ `}\n` +
325
+ `\n` +
326
+ `function parseAgentId(sessionKey) {\n` +
327
+ ` const s = normalize(sessionKey);\n` +
328
+ ` if (!s || !s.startsWith('agent:')) return null;\n` +
329
+ ` const parts = s.split(':');\n` +
330
+ ` return parts.length >= 2 ? normalize(parts[1]) : null;\n` +
331
+ `}\n` +
332
+ `\n` +
333
+ `function resolveSessionId(event) {\n` +
334
+ ` const ctx = (event && event.context && typeof event.context === 'object') ? event.context : {};\n` +
335
+ ` return (\n` +
336
+ ` normalize(ctx.previousSessionEntry && ctx.previousSessionEntry.sessionId) ||\n` +
337
+ ` normalize(ctx.previousSessionId) ||\n` +
338
+ ` normalize(ctx.sessionEntry && ctx.sessionEntry.sessionId) ||\n` +
339
+ ` normalize(ctx.sessionId)\n` +
340
+ ` );\n` +
341
+ `}\n`;
342
+ }
343
+
344
+ function normalizeString(value) {
345
+ if (typeof value !== 'string') return null;
346
+ const trimmed = value.trim();
347
+ return trimmed.length > 0 ? trimmed : null;
348
+ }
349
+
350
+ module.exports = {
351
+ OPENCLAW_HOOK_NAME,
352
+ OPENCLAW_HOOK_DIRNAME,
353
+ resolveOpenclawHookPaths,
354
+ ensureOpenclawHookFiles,
355
+ installOpenclawHook,
356
+ probeOpenclawHookState,
357
+ removeOpenclawHookConfig
358
+ };